futu_rest/routes/trd/
unlock.rs1use std::net::SocketAddr;
4use std::sync::Arc;
5
6use axum::extract::{ConnectInfo, Extension, Json, State};
7use axum::http::StatusCode;
8use serde_json::Value;
9
10use futu_auth::KeyRecord;
11use futu_core::proto_id;
12use futu_proto::trd_unlock_trade;
13
14use super::ApiResult;
15use crate::adapter::{self, RestState};
16
17pub async fn unlock_trade(
30 State(state): State<RestState>,
31 rec: Option<Extension<Arc<KeyRecord>>>,
32 peer: Option<Extension<ConnectInfo<SocketAddr>>>,
33 Json(mut body): Json<Value>,
34) -> ApiResult {
35 let peer_addr = peer.map(|Extension(ConnectInfo(addr))| addr);
36 if !unlock_transport_is_trusted(peer_addr) {
37 return Err((
38 StatusCode::FORBIDDEN,
39 Json(serde_json::json!({
40 "error": "remote plaintext REST unlock is forbidden",
41 "diagnostic_key": "rest.unlock_trade.local_transport_required",
42 "action": "call /api/unlock-trade from loopback, or terminate TLS in a same-host reverse proxy that forwards to the loopback REST listener"
43 })),
44 ));
45 }
46
47 apply_unlock_trade_otp_aliases(&mut body);
49
50 if let Some(Extension(rec_ref)) = rec.as_ref()
59 && let Some(allowed) = &rec_ref.allowed_acc_ids
60 && !allowed.is_empty()
61 {
62 let acc_ids = match extract_unlock_trade_acc_ids(&body) {
63 Ok(Some(acc_ids)) => acc_ids,
64 Ok(None) => Vec::new(),
65 Err(reason) => {
66 return Err((
67 StatusCode::BAD_REQUEST,
68 Json(serde_json::json!({
69 "error": format!("/api/unlock-trade: {reason}")
70 })),
71 ));
72 }
73 };
74 if acc_ids.is_empty() {
75 return Err((
77 StatusCode::FORBIDDEN,
78 Json(serde_json::json!({
79 "error": format!(
80 "API key {:?} has allowed_acc_ids restriction but \
81 acc_ids not specified. Restricted keys must explicitly \
82 pass acc_ids; unlock-all is rejected to prevent unauthorized \
83 broker unlock side effects.",
84 rec_ref.id
85 ),
86 "hint": "pass acc_ids: [<your-allowed-acc-id>] in request body c2s",
87 })),
88 ));
89 }
90 for id in &acc_ids {
91 if !allowed.contains(id) {
92 futu_auth::audit::reject(
93 "rest",
94 "/api/unlock-trade",
95 &rec_ref.id,
96 &format!("acc_id {id} not in allowed list"),
97 );
98 return Err((
99 StatusCode::FORBIDDEN,
100 Json(serde_json::json!({ "error": "forbidden" })),
101 ));
102 }
103 }
104 }
105
106 let resp = adapter::proto_request::<trd_unlock_trade::Request, trd_unlock_trade::Response>(
107 &state,
108 proto_id::TRD_UNLOCK_TRADE,
109 Some(body),
110 )
111 .await?;
112
113 let Json(mut v) = resp;
115 let is_err = v
116 .get("ret_type")
117 .and_then(|t| t.as_i64())
118 .map(|t| t != 0)
119 .unwrap_or(false);
120 if is_err && let Some(msg) = v.get("ret_msg").and_then(|m| m.as_str()) {
121 if msg.contains("交易密码") || msg.contains("密码错误") || msg.contains("密码输入")
123 {
124 let hint = "[提示] 交易密码与登录密码独立;如未设置,先用 \
125 `futucli set-trade-pwd` 存入 OS keychain,重复错密码后券商会锁账户。";
126 let new_msg = format!("{msg} {hint}");
127 if let Some(obj) = v.as_object_mut() {
128 obj.insert("ret_msg".to_string(), Value::String(new_msg));
129 }
130 }
131 }
132 Ok(Json(v))
133}
134
135pub(crate) fn unlock_transport_is_trusted(peer: Option<SocketAddr>) -> bool {
136 peer.is_none_or(|addr| addr.ip().is_loopback())
139}
140
141fn extract_unlock_trade_acc_ids(body: &Value) -> Result<Option<Vec<u64>>, String> {
142 let Some(c2s) = body.get("c2s") else {
143 return Ok(None);
144 };
145 let Some(raw) = c2s.get("acc_ids").or_else(|| c2s.get("accIds")) else {
146 return Ok(None);
147 };
148 let Some(items) = raw.as_array() else {
149 return Err("c2s.acc_ids must be an array of positive integer acc_id values".to_string());
150 };
151
152 let mut acc_ids = Vec::with_capacity(items.len());
153 for (idx, value) in items.iter().enumerate() {
154 let Some(acc_id) = value.as_u64() else {
155 return Err(format!(
156 "c2s.acc_ids[{idx}] must be a positive integer acc_id"
157 ));
158 };
159 if acc_id == 0 {
160 return Err(format!(
161 "c2s.acc_ids[{idx}] contains zero — call /api/accounts to discover real acc_id values"
162 ));
163 }
164 acc_ids.push(acc_id);
165 }
166 Ok(Some(acc_ids))
167}
168
169pub(crate) fn apply_unlock_trade_otp_aliases(body: &mut Value) {
191 const ALIAS_KEYS: &[&str] = &["otp", "token", "one_time_password", "oneTimePassword"];
195
196 fn rename_alias_in_object(map: &mut serde_json::Map<String, Value>) {
197 let has_canonical = map.contains_key("sec_otp") || map.contains_key("secOtp");
201 if has_canonical {
202 for alias in ALIAS_KEYS {
204 map.remove(*alias);
205 }
206 return;
207 }
208 let mut renamed = false;
210 for alias in ALIAS_KEYS {
211 if let Some(v) = map.remove(*alias)
212 && !renamed
213 {
214 map.insert("sec_otp".to_string(), v);
215 renamed = true;
216 }
217 }
219 }
220 if let Some(map) = body.as_object_mut() {
221 rename_alias_in_object(map);
222 }
223 if let Some(c2s) = body.get_mut("c2s").and_then(|c| c.as_object_mut()) {
224 rename_alias_in_object(c2s);
225 }
226}
227
228#[cfg(test)]
229mod acc_ids_tests;
230
231#[cfg(test)]
232mod otp_alias_tests;
233
234#[cfg(test)]
235mod transport_tests;