Skip to main content

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}