wecomx_auth/bootstrap.rs
1//! 鉴权引导:botid+secret 签名调用换取 Bearer token。
2//!
3//! 签名算法为 `sha256_hex(secret + bot_id + time + nonce)`;返回的 token
4//! 由调用方(如 `auth init` / [`BotGatewayTokenProvider`](crate::provider))
5//! 统一保存至凭据存储,后续请求经 `Authorization: Bearer <token>` 注入。
6
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11use serde_repr::Serialize_repr;
12use sha2::{Digest, Sha256};
13
14use wecomx_transport::EndpointHttpExt;
15
16use crate::bot::BotCredential;
17use crate::error::AuthError;
18
19// ---------------------------------------------------------------------------
20// Request ID
21// ---------------------------------------------------------------------------
22
23/// Generate a request ID in the format: `{prefix}_{timestamp_ms}_{random_hex}`.
24fn gen_req_id(prefix: &str) -> String {
25 let timestamp = std::time::SystemTime::now()
26 .duration_since(std::time::UNIX_EPOCH)
27 .unwrap_or_default()
28 .as_millis();
29 let random = generate_random_hex(8);
30 format!("{prefix}_{timestamp}_{random}")
31}
32
33/// Generate a random hex string of the specified character length.
34fn generate_random_hex(length: usize) -> String {
35 use rand::RngExt;
36 let byte_len = length.div_ceil(2);
37 let bytes: Vec<u8> = (0..byte_len).map(|_| rand::rng().random::<u8>()).collect();
38 let hex = hex::encode(bytes);
39 hex[..length].to_string()
40}
41
42// ---------------------------------------------------------------------------
43// Request
44// ---------------------------------------------------------------------------
45
46/// 配置来源
47#[derive(Debug, Clone, Copy, Serialize_repr)]
48#[repr(u8)]
49pub enum BindSource {
50 /// Interactive
51 Interactive = 1,
52 /// QR Code
53 Qrcode = 2,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct FetchAuthRequest {
58 pub bot_id: String,
59 pub time: u64,
60 pub nonce: String,
61 pub signature: String,
62 pub bind_source: BindSource,
63}
64
65impl FetchAuthRequest {
66 /// Build a signed request from the given bot credentials
67 pub fn build(bot: &BotCredential, bind_source: BindSource) -> Result<Self, AuthError> {
68 let time = SystemTime::now()
69 .duration_since(UNIX_EPOCH)
70 .unwrap_or_default()
71 .as_secs();
72 let nonce = gen_req_id("cli");
73 let signature = sign(&bot.secret, &bot.id, time, &nonce);
74
75 Ok(Self {
76 bot_id: bot.id.clone(),
77 time,
78 nonce,
79 signature,
80 bind_source,
81 })
82 }
83}
84
85// ---------------------------------------------------------------------------
86// Response
87// ---------------------------------------------------------------------------
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct FetchAuthResponse {
91 #[serde(default)]
92 pub errcode: i32,
93 pub errmsg: Option<String>,
94 /// 鉴权成功后台返回的 Bearer token(后续请求经 Authorization 头携带)。
95 #[serde(default)]
96 pub token: Option<String>,
97 #[serde(flatten)]
98 pub extra: IndexMap<String, serde_json::Value>,
99}
100
101// ---------------------------------------------------------------------------
102// Signature
103// ---------------------------------------------------------------------------
104
105/// Compute the request signature.
106///
107/// Algorithm: `sha256_hex(secret + bot_id + time + nonce)`
108/// where `sha256_hex` uses the standard zero-padded lowercase hex format (`%02x`).
109pub fn sign(secret: &str, bot_id: &str, time: u64, nonce: &str) -> String {
110 let input = format!("{secret}{bot_id}{time}{nonce}");
111 sha256_hex(&input)
112}
113
114/// Compute the SHA-256 hash of `input` and return it as a lowercase hex string.
115fn sha256_hex(input: &str) -> String {
116 let hash = Sha256::digest(input.as_bytes());
117 let mut result = String::with_capacity(64);
118 for byte in hash.iter() {
119 result.push_str(&format!("{:02x}", byte));
120 }
121 result
122}
123
124// ---------------------------------------------------------------------------
125// API Call
126// ---------------------------------------------------------------------------
127
128/// Fetch the auth bootstrap config from the server (signed request), returning
129/// the Bearer token for the caller to persist.
130///
131/// 复用调用方的请求能力;`endpoint` 应经 [`crate::gateway::auth_endpoint`]
132/// 装配(扁平信封与鉴权抑制标记由它保证)。
133///
134/// # Errors
135///
136/// 网络/HTTP/解析经 [`AuthError::Transport`] 透传;业务错误(`errcode != 0`)
137/// 由 [`FlatRes`](crate::gateway::FlatRes) 信封层校验并构造
138/// `wecomx_transport::Error::Api`(消息取后台 errmsg,body 透传原始响应);
139/// 响应格式不符为 transport 层 [`Parse`](wecomx_transport::Error::Parse)
140/// (含原始 body 与 serde source)。
141pub async fn fetch_auth(
142 transport: &wecomx_transport::Transport,
143 bot: &BotCredential,
144 bind_source: BindSource,
145 endpoint: &wecomx_transport::Endpoint,
146) -> Result<FetchAuthResponse, AuthError> {
147 tracing::debug!(bind_source = ?bind_source, "auth bootstrap request");
148 let request = FetchAuthRequest::build(bot, bind_source)
149 .inspect_err(|e| tracing::error!(error = %e, "build auth bootstrap request failed"))?;
150
151 // 纯字符串字段的结构体序列化失败为意料之外的系统级失败,归入兜底 Other。
152 let payload = serde_json::to_value(&request).map_err(|e| AuthError::Other(e.into()))?;
153
154 let value = transport.invoke(endpoint, &payload).await?.into_result()?;
155
156 let resp = FetchAuthResponse::deserialize(&value).map_err(|e| {
157 AuthError::from(wecomx_transport::Error::Parse {
158 message: format!("鉴权响应格式异常: {e}"),
159 endpoint: EndpointHttpExt::full_url(endpoint),
160 body: Box::new(value),
161 source: Some(e),
162 })
163 })?;
164
165 Ok(resp)
166}
167
168#[cfg(test)]
169mod tests {
170 //! ## 模块摘要:bootstrap(鉴权引导:botid+secret 签名换取 Bearer token)
171 //!
172 //! ### 关键接口
173 //! - [sign] — `sha256_hex(secret + bot_id + time + nonce)` 签名算法
174 //! - [sha256_hex] — SHA-256 小写零填充 hex
175 //! - [FetchAuthRequest::build] — 构建带签名的请求体(time/nonce/signature/bind_source)
176 //! - [BindSource] — 绑定来源枚举(Interactive=1 / Qrcode=2,序列化为数字)
177 //!
178 //! ### 关键分支与异常路径
179 //! - `sha256_hex` 与 C++ 参考实现(`%02x` 小写零填充)一致
180 //! - 签名确定性:相同输入 → 相同输出;不同 nonce → 不同签名
181 //! - 请求体含 `bind_source`(绑定来源)与 `bot_id`
182
183 use super::*;
184
185 // -----------------------------------------------------------------------
186 // Signature tests
187 // -----------------------------------------------------------------------
188
189 /// P0:sha256_hex 输出与 C++ 参考格式(%02x 小写零填充)一致
190 /// 条件:对字符串 "test" 计算 sha256_hex
191 /// 断言:输出等于已知的 64 位小写 hex 参考值
192 #[test]
193 fn sha256_hex_matches_cpp_format() {
194 let result = sha256_hex("test");
195 // {:02x} format: standard lowercase hex, two digits per byte, zero-padded
196 assert_eq!(
197 result,
198 "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
199 );
200 }
201
202 /// P0:sign 产生非空签名
203 /// 条件:给定 secret/bot_id/time/nonce 调用 sign
204 /// 断言:签名字符串非空
205 #[test]
206 fn sign_produces_non_empty_signature() {
207 let sig = sign("my_secret", "bot_123", 1774772074, "abc123");
208 assert!(!sig.is_empty());
209 }
210
211 /// P0:sign 对相同输入输出确定性结果
212 /// 条件:相同 secret/id/time/nonce 调用两次 sign
213 /// 断言:两次签名相等
214 #[test]
215 fn sign_is_deterministic() {
216 let a = sign("sec", "id", 100, "nonce");
217 let b = sign("sec", "id", 100, "nonce");
218 assert_eq!(a, b);
219 }
220
221 /// P1:不同输入产生不同签名
222 /// 条件:仅 nonce 不同调用两次 sign
223 /// 断言:两次签名不相等
224 #[test]
225 fn sign_changes_with_different_inputs() {
226 let a = sign("sec", "id", 100, "nonce1");
227 let b = sign("sec", "id", 100, "nonce2");
228 assert_ne!(a, b);
229 }
230
231 // -----------------------------------------------------------------------
232 // Serialization tests
233 // -----------------------------------------------------------------------
234
235 /// P0:BindSource 序列化为数字
236 /// 条件:分别序列化 Interactive 与 Qrcode
237 /// 断言:输出为字符串 "1" 与 "2"
238 #[test]
239 fn bind_source_serializes_as_number() {
240 let json = serde_json::to_string(&BindSource::Interactive).unwrap();
241 assert_eq!(json, "1", "Expected number 1, got: {json}");
242 let json = serde_json::to_string(&BindSource::Qrcode).unwrap();
243 assert_eq!(json, "2", "Expected number 2, got: {json}");
244 }
245
246 /// P0:请求体含 `bind_source` 与 `bot_id`
247 /// 条件:构造 FetchAuthRequest::build 并序列化为 JSON
248 /// 断言:包含 bind_source/bot_id 字段
249 #[test]
250 fn fetch_auth_request_includes_required_fields() {
251 let bot = BotCredential::new("b".into(), "s".into());
252 let req = FetchAuthRequest::build(&bot, BindSource::Interactive).unwrap();
253 let json = serde_json::to_value(&req).unwrap();
254 assert!(json.get("bind_source").is_some());
255 assert!(json.get("bot_id").is_some());
256 }
257}