920 lines
31 KiB
Rust
920 lines
31 KiB
Rust
//! AI password-book path (list / get / resolve / login / session) without mnote-web.
|
|
|
|
use crate::error::VaultError;
|
|
use crate::store::{
|
|
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
|
|
load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher,
|
|
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
|
|
resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book,
|
|
VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
|
};
|
|
use crate::token::DEFAULT_ACTOR;
|
|
use serde_json::{json, Value};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
const DEFAULT_DATA_BASE: &str = "/mnt/Data1T/Mnote_data";
|
|
|
|
pub fn ai_vault_actor_id() -> String {
|
|
std::env::var("MNOTE_AI_VAULT_ACTOR")
|
|
.ok()
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
.unwrap_or_else(|| DEFAULT_ACTOR.to_string())
|
|
}
|
|
|
|
/// Encode actor id for managed path segment (aligned with mnote-web).
|
|
///
|
|
/// 不保留 `.` / `/` 等路径元字符,避免 actor=`..` 时穿越 `users/` 目录。
|
|
pub fn encode_actor_segment(actor_id: &str) -> String {
|
|
actor_id
|
|
.chars()
|
|
.map(|c| match c {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c.to_string(),
|
|
other => format!("~{:02x}", other as u32),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn managed_data_base() -> PathBuf {
|
|
std::env::var("MNOTE_DATA_DIR")
|
|
.or_else(|_| std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR"))
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_BASE))
|
|
}
|
|
|
|
pub fn ai_vault_workspace_root() -> PathBuf {
|
|
if let Ok(p) = std::env::var("MNOTE_VAULT_WORKSPACE") {
|
|
let p = p.trim();
|
|
if !p.is_empty() {
|
|
return PathBuf::from(p);
|
|
}
|
|
}
|
|
let actor = encode_actor_segment(&ai_vault_actor_id());
|
|
managed_data_base()
|
|
.join("users")
|
|
.join(actor)
|
|
.join("workspaces")
|
|
.join("my-space")
|
|
}
|
|
|
|
pub fn ensure_ai_vault_workspace() -> Result<PathBuf, VaultError> {
|
|
let root = ai_vault_workspace_root();
|
|
if !root.exists() {
|
|
std::fs::create_dir_all(root.join(".mnote")).map_err(|e| {
|
|
VaultError::bad_request_code(
|
|
"vault_ai_root_unavailable",
|
|
format!("无法创建 AI 密码本工作区 {}: {e}", root.display()),
|
|
)
|
|
})?;
|
|
}
|
|
let root = root.canonicalize().map_err(|e| {
|
|
VaultError::bad_request_code(
|
|
"vault_ai_root_unavailable",
|
|
format!("无法访问 AI 密码本工作区: {e}"),
|
|
)
|
|
})?;
|
|
let _ = ensure_vault_directories(&root)?;
|
|
Ok(root)
|
|
}
|
|
|
|
fn normalize_secret_field(field: &str) -> Result<&'static str, VaultError> {
|
|
match field.trim() {
|
|
"password" => Ok("password"),
|
|
"apikey" | "apiKey" | "api_key" => Ok("apikey"),
|
|
"token" => Ok("token"),
|
|
"username" => Ok("username"),
|
|
"email" => Ok("email"),
|
|
other => Err(VaultError::bad_request_code(
|
|
"vault_resolve_field_invalid",
|
|
format!("不支持字段: {other};允许 password|apikey|token|username|email"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, VaultError> {
|
|
let root = ensure_ai_vault_workspace()?;
|
|
let (index, items) = list_credentials(&root, status)?;
|
|
let book = load_cipher_book(&root).ok();
|
|
let book_ref = book.as_ref();
|
|
let projected: Vec<Value> = items
|
|
.iter()
|
|
.map(|e| project_list_entry_with_cipher(e, book_ref))
|
|
.collect();
|
|
Ok(json!({
|
|
"schema": "mnote.vault.list.v1",
|
|
"status": status.as_str(),
|
|
"revision": index.revision,
|
|
"updatedAt": index.updated_at,
|
|
"items": projected,
|
|
"isAiVault": true,
|
|
"aiVaultActorId": ai_vault_actor_id(),
|
|
"vaultRole": "ai",
|
|
"transport": "local-core",
|
|
}))
|
|
}
|
|
|
|
pub fn get_ai_vault_item(credential_id: &str) -> Result<Value, VaultError> {
|
|
let root = ensure_ai_vault_workspace()?;
|
|
let record = get_credential(&root, credential_id)?;
|
|
Ok(json!({
|
|
"item": project_item_l0_with_cipher(&record, Some(&root)),
|
|
"isAiVault": true,
|
|
"aiVaultActorId": ai_vault_actor_id(),
|
|
"transport": "local-core",
|
|
}))
|
|
}
|
|
|
|
pub fn resolve_ai_vault_secret(
|
|
credential_id: &str,
|
|
field: &str,
|
|
actor: &str,
|
|
request_id: Option<&str>,
|
|
account_id: Option<&str>,
|
|
secret_id: Option<&str>,
|
|
) -> Result<Value, VaultError> {
|
|
let field_norm = normalize_secret_field(field)?;
|
|
let account_id = account_id.map(str::trim).filter(|v| !v.is_empty());
|
|
let secret_id = secret_id.map(str::trim).filter(|v| !v.is_empty());
|
|
let root = ensure_ai_vault_workspace()?;
|
|
let record = match get_credential(&root, credential_id) {
|
|
Ok(r) => r,
|
|
Err(err) => {
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"resolve",
|
|
actor,
|
|
credential_id,
|
|
Some(field_norm),
|
|
request_id,
|
|
false,
|
|
);
|
|
return Err(err);
|
|
}
|
|
};
|
|
if record.status != VaultItemStatus::Active {
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"resolve",
|
|
actor,
|
|
credential_id,
|
|
Some(field_norm),
|
|
request_id,
|
|
false,
|
|
);
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_resolve_inactive",
|
|
"只能 resolve 在用条目",
|
|
));
|
|
}
|
|
let secret = resolve_record_secret_field(&record, field_norm, account_id, secret_id);
|
|
let secret_proj = match project_secret_revealed(&root, secret) {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"resolve",
|
|
actor,
|
|
credential_id,
|
|
Some(field_norm),
|
|
request_id,
|
|
false,
|
|
);
|
|
return Err(err);
|
|
}
|
|
};
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"resolve",
|
|
actor,
|
|
credential_id,
|
|
Some(field_norm),
|
|
request_id,
|
|
true,
|
|
);
|
|
let value = secret_proj
|
|
.get("value")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let state = secret_proj
|
|
.get("state")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("absent");
|
|
let mut out = json!({
|
|
"schema": "mnote.vault.resolve.v1",
|
|
"id": credential_id,
|
|
"field": field_norm,
|
|
"resolved": state == "revealed",
|
|
"state": state,
|
|
"value": if state == "revealed" { Value::String(value.to_string()) } else { Value::Null },
|
|
"usedCipherKeys": secret_proj.get("usedCipherKeys").cloned().unwrap_or(json!([])),
|
|
"missingCipherKeys": secret_proj.get("missingCipherKeys").cloned().unwrap_or(json!([])),
|
|
"template": secret_proj.get("template").cloned().unwrap_or(Value::Null),
|
|
"aiVaultActorId": ai_vault_actor_id(),
|
|
"transcriptHint": format!("已解析 {field_norm}"),
|
|
"note": "多 agent 唯一读密通道;勿把 value 写入聊天/commit/RAG",
|
|
"transport": "local-core",
|
|
});
|
|
if let Some(aid) = account_id {
|
|
out["accountId"] = json!(aid);
|
|
}
|
|
if let Some(sid) = secret_id {
|
|
out["secretId"] = json!(sid);
|
|
}
|
|
let _ = store::vault_root(Path::new(&root));
|
|
Ok(out)
|
|
}
|
|
|
|
fn default_session_expires_hours() -> i64 {
|
|
std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS")
|
|
.ok()
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.filter(|h| *h > 0)
|
|
.unwrap_or(168) // 7 days
|
|
}
|
|
|
|
fn session_expires_rfc3339_from_now() -> String {
|
|
let hours = default_session_expires_hours();
|
|
let t = time::OffsetDateTime::now_utc() + time::Duration::hours(hours);
|
|
t.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_else(|_| now_rfc3339())
|
|
}
|
|
|
|
fn origin_from_credential_url(url: Option<&str>) -> Result<String, VaultError> {
|
|
let raw = url
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| {
|
|
VaultError::bad_request_code(
|
|
"vault_login_url_required",
|
|
"条目缺少 url,无法推导登录 origin",
|
|
)
|
|
})?;
|
|
let with_scheme = if raw.contains("://") {
|
|
raw.to_string()
|
|
} else {
|
|
format!("https://{raw}")
|
|
};
|
|
let parsed = reqwest::Url::parse(&with_scheme).map_err(|e| {
|
|
VaultError::bad_request_code(
|
|
"vault_login_url_invalid",
|
|
format!("无法解析 url: {e}"),
|
|
)
|
|
})?;
|
|
if let Some(port) = parsed.port() {
|
|
return Ok(format!(
|
|
"{}://{}:{}",
|
|
parsed.scheme(),
|
|
parsed.host_str().unwrap_or(""),
|
|
port
|
|
));
|
|
}
|
|
let origin = format!(
|
|
"{}://{}",
|
|
parsed.scheme(),
|
|
parsed.host_str().unwrap_or("")
|
|
);
|
|
if origin.ends_with("://") {
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_url_invalid",
|
|
"url 缺少 host",
|
|
));
|
|
}
|
|
Ok(origin)
|
|
}
|
|
|
|
fn pick_login_account(
|
|
workspace_root: &Path,
|
|
record: &VaultCredentialRecord,
|
|
preferred: &str,
|
|
) -> String {
|
|
let pref = preferred.trim().to_ascii_lowercase();
|
|
let expand = |raw: Option<&str>| -> Option<String> {
|
|
let t = raw.map(str::trim).filter(|s| !s.is_empty())?;
|
|
match resolve_secret_with_cipher_book(workspace_root, Some(t)) {
|
|
Ok(Some(resolved)) => {
|
|
let v = resolved.value.trim().to_string();
|
|
if v.is_empty() {
|
|
None
|
|
} else {
|
|
Some(v)
|
|
}
|
|
}
|
|
_ => Some(t.to_string()),
|
|
}
|
|
};
|
|
let email = expand(record.email.as_deref()).or_else(|| {
|
|
record
|
|
.accounts
|
|
.first()
|
|
.and_then(|a| expand(a.email.as_deref()))
|
|
});
|
|
let user = expand(record.username.as_deref()).or_else(|| {
|
|
record
|
|
.accounts
|
|
.first()
|
|
.and_then(|a| expand(a.username.as_deref()))
|
|
});
|
|
match pref.as_str() {
|
|
"email" => email.or(user).unwrap_or_default(),
|
|
"username" => user.or(email).unwrap_or_default(),
|
|
_ => email.or(user).unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
fn merge_set_cookie_into_header(existing: &str, set_cookies: &[String]) -> String {
|
|
use std::collections::BTreeMap;
|
|
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
|
for part in existing.split(';') {
|
|
let p = part.trim();
|
|
if p.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some((k, v)) = p.split_once('=') {
|
|
map.insert(k.trim().to_string(), v.trim().to_string());
|
|
}
|
|
}
|
|
for sc in set_cookies {
|
|
let first = sc.split(';').next().unwrap_or("").trim();
|
|
if let Some((k, v)) = first.split_once('=') {
|
|
map.insert(k.trim().to_string(), v.trim().to_string());
|
|
}
|
|
}
|
|
map.iter()
|
|
.map(|(k, v)| format!("{k}={v}"))
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
}
|
|
|
|
/// Write browser-captured cookies into AI password book (no mnote-web).
|
|
pub fn put_ai_vault_session(
|
|
credential_id: &str,
|
|
cookie_header: &str,
|
|
expires_at: Option<&str>,
|
|
source: &str,
|
|
actor: &str,
|
|
request_id: Option<&str>,
|
|
) -> Result<Value, VaultError> {
|
|
let root = ensure_ai_vault_workspace()?;
|
|
let cookie = cookie_header.trim();
|
|
if cookie.is_empty() {
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_session_cookie_required",
|
|
"cookieHeader 不能为空",
|
|
));
|
|
}
|
|
let now = time::OffsetDateTime::now_utc()
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_else(|_| now_rfc3339());
|
|
let expires = expires_at
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(session_expires_rfc3339_from_now);
|
|
let session = VaultLoginSession {
|
|
cookie_header: Some(cookie.to_string()),
|
|
expires_at: Some(expires.clone()),
|
|
last_login_at: Some(now),
|
|
source: Some(source.trim().to_string()),
|
|
..Default::default()
|
|
};
|
|
let record = put_login_session(&root, credential_id, session)?;
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"session_put",
|
|
actor,
|
|
credential_id,
|
|
Some(source),
|
|
request_id,
|
|
true,
|
|
);
|
|
Ok(json!({
|
|
"schema": "mnote.vault.session.v1",
|
|
"id": credential_id,
|
|
"ok": true,
|
|
"hasLoginSession": true,
|
|
"expiresAt": expires,
|
|
"source": source,
|
|
"item": project_item_l0_with_cipher(&record, Some(&root)),
|
|
"transcriptHint": "已回写登录态",
|
|
"transport": "local-core",
|
|
}))
|
|
}
|
|
|
|
/// Local login against AI password book (no mnote-web):
|
|
/// reuse fresh session → else api_first password login → save session.
|
|
/// Cloudflare / captcha → human_required (agent uses browser tools then `session`).
|
|
pub fn login_ai_vault_credential(
|
|
credential_id: &str,
|
|
force_refresh: bool,
|
|
actor: &str,
|
|
request_id: Option<&str>,
|
|
) -> Result<Value, VaultError> {
|
|
let root = ensure_ai_vault_workspace()?;
|
|
let mut record = get_credential(&root, credential_id)?;
|
|
if record.status != VaultItemStatus::Active {
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_inactive",
|
|
"只能登录在用条目",
|
|
));
|
|
}
|
|
|
|
if record.login_playbook.is_none() {
|
|
let pb = VaultLoginPlaybook::default_for_credential(
|
|
record.email.as_deref(),
|
|
record.username.as_deref(),
|
|
);
|
|
record = put_login_playbook(&root, credential_id, pb)?;
|
|
}
|
|
let playbook = record.login_playbook.clone().unwrap_or_else(|| {
|
|
VaultLoginPlaybook::default_for_credential(
|
|
record.email.as_deref(),
|
|
record.username.as_deref(),
|
|
)
|
|
});
|
|
|
|
// Prefer per-account session files (12-3); frontmatter is fallback inside resolve.
|
|
let resolved_session = resolve_login_session(&root, &record, None).ok().flatten();
|
|
|
|
if playbook.is_human_required() && !force_refresh {
|
|
if let Some(sess) = resolved_session.as_ref() {
|
|
if login_session_is_fresh(sess) {
|
|
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"login_reuse",
|
|
actor,
|
|
credential_id,
|
|
Some("session"),
|
|
request_id,
|
|
true,
|
|
);
|
|
return Ok(json!({
|
|
"schema": "mnote.vault.login.v1",
|
|
"id": credential_id,
|
|
"reused": true,
|
|
"mode": "session",
|
|
"cookieHeader": cookie,
|
|
"expiresAt": sess.expires_at,
|
|
"accountId": sess.account_id,
|
|
"loginPlaybook": playbook,
|
|
"transcriptHint": "已复用登录态",
|
|
"note": "playbook=human_required;有可用 session 直接复用",
|
|
"transport": "local-core",
|
|
}));
|
|
}
|
|
}
|
|
return Ok(json!({
|
|
"schema": "mnote.vault.login.v1",
|
|
"id": credential_id,
|
|
"reused": false,
|
|
"mode": "human_required",
|
|
"ok": false,
|
|
"code": "vault_login_human_required",
|
|
"message": "需要人完成验证码/Cloudflare 后回写登录态",
|
|
"loginPlaybook": playbook,
|
|
"humanInstructions": {
|
|
"local": "用 chrome-bridge 打开 loginUrl 完成验证并登录,然后 mnote-vault session --id <id> --cookie-header '…'",
|
|
"remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写",
|
|
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
|
},
|
|
"transcriptHint": "需要人工验证后回写 session",
|
|
"transport": "local-core",
|
|
}));
|
|
}
|
|
|
|
if !force_refresh {
|
|
if let Some(sess) = resolved_session.as_ref() {
|
|
if login_session_is_fresh(sess) {
|
|
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"login_reuse",
|
|
actor,
|
|
credential_id,
|
|
Some("session"),
|
|
request_id,
|
|
true,
|
|
);
|
|
return Ok(json!({
|
|
"schema": "mnote.vault.login.v1",
|
|
"id": credential_id,
|
|
"reused": true,
|
|
"mode": "session",
|
|
"cookieHeader": cookie,
|
|
"expiresAt": sess.expires_at,
|
|
"source": sess.source,
|
|
"accountId": sess.account_id,
|
|
"loginPlaybook": {
|
|
"mode": playbook.mode,
|
|
"preferredAccount": playbook.preferred_account,
|
|
},
|
|
"accountHint": pick_login_account(&root, &record, &playbook.preferred_account),
|
|
"url": record.url,
|
|
"transcriptHint": "已复用登录态",
|
|
"transport": "local-core",
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
let password_resolved = resolve_ai_vault_secret(
|
|
credential_id,
|
|
"password",
|
|
actor,
|
|
request_id,
|
|
None,
|
|
None,
|
|
)?;
|
|
if password_resolved
|
|
.get("resolved")
|
|
.and_then(Value::as_bool)
|
|
!= Some(true)
|
|
{
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_no_password",
|
|
"条目无可用 password,无法自动登录",
|
|
));
|
|
}
|
|
let password = password_resolved
|
|
.get("value")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let account = pick_login_account(&root, &record, &playbook.preferred_account);
|
|
if account.is_empty() {
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_no_account",
|
|
"条目缺少 email/username",
|
|
));
|
|
}
|
|
|
|
let mode = playbook.mode.trim().to_ascii_lowercase();
|
|
if mode == "browser" {
|
|
return Ok(json!({
|
|
"schema": "mnote.vault.login.v1",
|
|
"id": credential_id,
|
|
"reused": false,
|
|
"mode": "browser",
|
|
"ok": false,
|
|
"code": "vault_login_browser_required",
|
|
"message": "playbook 要求浏览器登录;agent 用 chrome-bridge/Paseo 浏览器按 selectors 填表后 session 回写",
|
|
"loginPlaybook": playbook,
|
|
"account": account,
|
|
"passwordResolved": true,
|
|
"url": record.url,
|
|
"transcriptHint": "需要浏览器按 playbook 登录后回写 session",
|
|
"humanInstructions": {
|
|
"local": "chrome-bridge 登录后 mnote-vault session",
|
|
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
|
},
|
|
"transport": "local-core",
|
|
}));
|
|
}
|
|
|
|
// api_first (default) — outbound HTTP only; does not need mnote-web.
|
|
let origin = origin_from_credential_url(record.url.as_deref())?;
|
|
let api_path = playbook
|
|
.api_path
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or("/api/auth");
|
|
let login_url = if api_path.starts_with("http") {
|
|
api_path.to_string()
|
|
} else {
|
|
format!(
|
|
"{}{}",
|
|
origin.trim_end_matches('/'),
|
|
if api_path.starts_with('/') {
|
|
api_path.to_string()
|
|
} else {
|
|
format!("/{api_path}")
|
|
}
|
|
)
|
|
};
|
|
|
|
let body = json!({
|
|
"action": "auth:signIn",
|
|
"args": {
|
|
"provider": "password",
|
|
"params": {
|
|
"password": password,
|
|
"flow": "signIn",
|
|
"account": account,
|
|
"email": if account.contains('@') { Value::String(account.clone()) } else { Value::Null },
|
|
"name": if account.contains('@') {
|
|
account.split('@').next().unwrap_or(&account)
|
|
} else {
|
|
account.as_str()
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.map_err(|e| {
|
|
VaultError::bad_request_code("vault_login_client_failed", format!("HTTP 客户端: {e}"))
|
|
})?;
|
|
|
|
let response = client
|
|
.post(&login_url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&body)
|
|
.send()
|
|
.map_err(|e| {
|
|
let msg = e.to_string();
|
|
if msg.to_ascii_lowercase().contains("cloudflare")
|
|
|| msg.to_ascii_lowercase().contains("403")
|
|
{
|
|
VaultError::bad_request_code(
|
|
"vault_login_human_required",
|
|
format!("登录请求失败,可能需人机验证: {e}"),
|
|
)
|
|
} else {
|
|
VaultError::bad_request_code(
|
|
"vault_login_http_failed",
|
|
format!("登录请求失败: {e}"),
|
|
)
|
|
}
|
|
})?;
|
|
|
|
let status = response.status();
|
|
let set_cookies: Vec<String> = response
|
|
.headers()
|
|
.get_all(reqwest::header::SET_COOKIE)
|
|
.iter()
|
|
.filter_map(|v| v.to_str().ok().map(str::to_string))
|
|
.collect();
|
|
let resp_text = response.text().unwrap_or_default();
|
|
|
|
let lower = resp_text.to_ascii_lowercase();
|
|
if status.as_u16() == 403
|
|
|| lower.contains("cloudflare")
|
|
|| lower.contains("cf-challenge")
|
|
|| lower.contains("captcha")
|
|
|| lower.contains("just a moment")
|
|
{
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"login_challenge",
|
|
actor,
|
|
credential_id,
|
|
Some("human"),
|
|
request_id,
|
|
false,
|
|
);
|
|
return Ok(json!({
|
|
"schema": "mnote.vault.login.v1",
|
|
"id": credential_id,
|
|
"reused": false,
|
|
"mode": "human_required",
|
|
"ok": false,
|
|
"code": "vault_login_human_required",
|
|
"message": "检测到验证码/Cloudflare/拦截,需人完成验证后回写 session",
|
|
"httpStatus": status.as_u16(),
|
|
"loginPlaybook": playbook,
|
|
"url": record.url,
|
|
"transcriptHint": "需要人工验证后回写 session",
|
|
"humanInstructions": {
|
|
"local": "chrome-bridge 完成验证登录 → mnote-vault session",
|
|
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
|
},
|
|
"transport": "local-core",
|
|
}));
|
|
}
|
|
|
|
let mut json_ok = false;
|
|
if let Ok(v) = serde_json::from_str::<Value>(&resp_text) {
|
|
if v.get("error").is_none() && (status.is_success() || status.as_u16() == 200) {
|
|
json_ok = true;
|
|
}
|
|
if let Some(err) = v.get("error").and_then(Value::as_str) {
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"login_failed",
|
|
actor,
|
|
credential_id,
|
|
Some("api"),
|
|
request_id,
|
|
false,
|
|
);
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_rejected",
|
|
format!("远端登录拒绝: {err}"),
|
|
));
|
|
}
|
|
}
|
|
if !status.is_success() && !json_ok && set_cookies.is_empty() {
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"login_failed",
|
|
actor,
|
|
credential_id,
|
|
Some("api"),
|
|
request_id,
|
|
false,
|
|
);
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_rejected",
|
|
format!("远端登录失败 HTTP {}", status.as_u16()),
|
|
));
|
|
}
|
|
|
|
let cookie_header = merge_set_cookie_into_header("", &set_cookies);
|
|
if cookie_header.is_empty() {
|
|
return Err(VaultError::bad_request_code(
|
|
"vault_login_no_cookie",
|
|
"登录响应未包含 Set-Cookie;请改用浏览器登录并 session 回写",
|
|
));
|
|
}
|
|
|
|
let now = time::OffsetDateTime::now_utc()
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_else(|_| "now".into());
|
|
let expires = session_expires_rfc3339_from_now();
|
|
let session = VaultLoginSession {
|
|
cookie_header: Some(cookie_header.clone()),
|
|
expires_at: Some(expires.clone()),
|
|
last_login_at: Some(now),
|
|
source: Some("api".into()),
|
|
..Default::default()
|
|
};
|
|
let _ = put_login_session(&root, credential_id, session)?;
|
|
let _ = append_vault_audit(
|
|
&root,
|
|
"login_ok",
|
|
actor,
|
|
credential_id,
|
|
Some("api"),
|
|
request_id,
|
|
true,
|
|
);
|
|
|
|
Ok(json!({
|
|
"schema": "mnote.vault.login.v1",
|
|
"id": credential_id,
|
|
"reused": false,
|
|
"mode": "api",
|
|
"ok": true,
|
|
"cookieHeader": cookie_header,
|
|
"expiresAt": expires,
|
|
"account": account,
|
|
"url": record.url,
|
|
"loginPlaybook": {
|
|
"mode": playbook.mode,
|
|
"preferredAccount": playbook.preferred_account,
|
|
},
|
|
"transcriptHint": "已登录并保存登录态",
|
|
"note": "下次同 id 调用 login 将优先复用 session,直到过期或 forceRefresh",
|
|
"transport": "local-core",
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::store::{
|
|
create_credential, purge_credential, soft_delete_credential, VaultCreateInput,
|
|
};
|
|
use std::sync::Mutex;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
static AI_TEST_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
#[test]
|
|
fn encode_actor_segment_rejects_path_traversal_dots() {
|
|
// `..` 不得原样保留,否则 join("users", "..") 可穿越目录。
|
|
let encoded = encode_actor_segment("..");
|
|
assert!(!encoded.contains('.'), "encoded={encoded}");
|
|
assert_eq!(encoded, "~2e~2e");
|
|
let dotted = encode_actor_segment("a.b");
|
|
assert!(!dotted.contains('.'), "encoded={dotted}");
|
|
assert_eq!(dotted, "a~2eb");
|
|
}
|
|
|
|
fn with_temp_workspace<F, R>(f: F) -> R
|
|
where
|
|
F: FnOnce(&Path) -> R,
|
|
{
|
|
let _guard = AI_TEST_LOCK.lock().unwrap();
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let root = std::env::temp_dir().join(format!("mnote-vault-ai-session-{nanos}"));
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::env::set_var("MNOTE_VAULT_WORKSPACE", &root);
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(root.as_path())));
|
|
std::env::remove_var("MNOTE_VAULT_WORKSPACE");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
match result {
|
|
Ok(v) => v,
|
|
Err(payload) => std::panic::resume_unwind(payload),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn put_session_then_login_reuses_without_http() {
|
|
with_temp_workspace(|root| {
|
|
let created = create_credential(
|
|
root,
|
|
VaultCreateInput {
|
|
title: "session-reuse-core".into(),
|
|
url: Some("https://example.com/auth".into()),
|
|
email: Some("a@example.com".into()),
|
|
password: Some("secret-pass".into()),
|
|
tags: vec!["ai-shared".into()],
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("create");
|
|
let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(2))
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap();
|
|
let put = put_ai_vault_session(
|
|
&created.id,
|
|
"mnote_session=abc; other=1",
|
|
Some(&expires),
|
|
"human_bridge",
|
|
"tester",
|
|
Some("req-session"),
|
|
)
|
|
.expect("session put");
|
|
assert_eq!(put["ok"], true);
|
|
assert_eq!(put["hasLoginSession"], true);
|
|
assert_eq!(put["transport"], "local-core");
|
|
|
|
let login = login_ai_vault_credential(&created.id, false, "tester", None)
|
|
.expect("login reuse");
|
|
assert_eq!(login["reused"], true);
|
|
assert_eq!(login["mode"], "session");
|
|
assert_eq!(login["cookieHeader"], "mnote_session=abc; other=1");
|
|
assert_eq!(login["transport"], "local-core");
|
|
|
|
let _ = soft_delete_credential(root, &created.id);
|
|
let _ = purge_credential(root, &created.id);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn human_required_without_session_returns_instructions() {
|
|
with_temp_workspace(|root| {
|
|
let created = create_credential(
|
|
root,
|
|
VaultCreateInput {
|
|
title: "human-required-core".into(),
|
|
url: Some("https://example.com/login".into()),
|
|
email: Some("h@example.com".into()),
|
|
password: Some("pw".into()),
|
|
tags: vec!["ai-shared".into()],
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("create");
|
|
let mut pb = VaultLoginPlaybook::default_for_credential(Some("h@example.com"), None);
|
|
pb.mode = "human_required".into();
|
|
let _ = put_login_playbook(root, &created.id, pb).expect("playbook");
|
|
|
|
let login = login_ai_vault_credential(&created.id, false, "tester", None)
|
|
.expect("login human");
|
|
assert_eq!(login["reused"], false);
|
|
assert_eq!(login["mode"], "human_required");
|
|
assert_eq!(login["ok"], false);
|
|
assert_eq!(login["code"], "vault_login_human_required");
|
|
assert!(login["humanInstructions"]["cli"].as_str().unwrap().contains(&created.id));
|
|
|
|
let _ = soft_delete_credential(root, &created.id);
|
|
let _ = purge_credential(root, &created.id);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn put_session_rejects_empty_cookie() {
|
|
with_temp_workspace(|root| {
|
|
let created = create_credential(
|
|
root,
|
|
VaultCreateInput {
|
|
title: "empty-cookie".into(),
|
|
email: Some("e@example.com".into()),
|
|
password: Some("pw".into()),
|
|
tags: vec!["ai-shared".into()],
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("create");
|
|
let err = put_ai_vault_session(&created.id, " ", None, "api", "tester", None)
|
|
.expect_err("empty cookie");
|
|
assert_eq!(err.code, "vault_session_cookie_required");
|
|
let _ = soft_delete_credential(root, &created.id);
|
|
let _ = purge_credential(root, &created.id);
|
|
});
|
|
}
|
|
}
|