use std::time::{SystemTime, UNIX_EPOCH};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_repr::Serialize_repr;
use sha2::{Digest, Sha256};
use wecomx_transport::EndpointHttpExt;
use crate::bot::BotCredential;
use crate::error::AuthError;
fn gen_req_id(prefix: &str) -> String {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let random = generate_random_hex(8);
format!("{prefix}_{timestamp}_{random}")
}
fn generate_random_hex(length: usize) -> String {
use rand::RngExt;
let byte_len = length.div_ceil(2);
let bytes: Vec<u8> = (0..byte_len).map(|_| rand::rng().random::<u8>()).collect();
let hex = hex::encode(bytes);
hex[..length].to_string()
}
#[derive(Debug, Clone, Copy, Serialize_repr)]
#[repr(u8)]
pub enum BindSource {
Interactive = 1,
Qrcode = 2,
}
#[derive(Debug, Clone, Serialize)]
pub struct FetchAuthRequest {
pub bot_id: String,
pub time: u64,
pub nonce: String,
pub signature: String,
pub bind_source: BindSource,
}
impl FetchAuthRequest {
pub fn build(bot: &BotCredential, bind_source: BindSource) -> Result<Self, AuthError> {
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let nonce = gen_req_id("cli");
let signature = sign(&bot.secret, &bot.id, time, &nonce);
Ok(Self {
bot_id: bot.id.clone(),
time,
nonce,
signature,
bind_source,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FetchAuthResponse {
#[serde(default)]
pub errcode: i32,
pub errmsg: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(flatten)]
pub extra: IndexMap<String, serde_json::Value>,
}
pub fn sign(secret: &str, bot_id: &str, time: u64, nonce: &str) -> String {
let input = format!("{secret}{bot_id}{time}{nonce}");
sha256_hex(&input)
}
fn sha256_hex(input: &str) -> String {
let hash = Sha256::digest(input.as_bytes());
let mut result = String::with_capacity(64);
for byte in hash.iter() {
result.push_str(&format!("{:02x}", byte));
}
result
}
pub async fn fetch_auth(
transport: &wecomx_transport::Transport,
bot: &BotCredential,
bind_source: BindSource,
endpoint: &wecomx_transport::Endpoint,
) -> Result<FetchAuthResponse, AuthError> {
tracing::debug!(bind_source = ?bind_source, "auth bootstrap request");
let request = FetchAuthRequest::build(bot, bind_source)
.inspect_err(|e| tracing::error!(error = %e, "build auth bootstrap request failed"))?;
let payload = serde_json::to_value(&request).map_err(|e| AuthError::Other(e.into()))?;
let value = transport.invoke(endpoint, &payload).await?.into_result()?;
let resp = FetchAuthResponse::deserialize(&value).map_err(|e| {
AuthError::from(wecomx_transport::Error::Parse {
message: format!("鉴权响应格式异常: {e}"),
endpoint: EndpointHttpExt::full_url(endpoint),
body: Box::new(value),
source: Some(e),
})
})?;
Ok(resp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_hex_matches_cpp_format() {
let result = sha256_hex("test");
assert_eq!(
result,
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
);
}
#[test]
fn sign_produces_non_empty_signature() {
let sig = sign("my_secret", "bot_123", 1774772074, "abc123");
assert!(!sig.is_empty());
}
#[test]
fn sign_is_deterministic() {
let a = sign("sec", "id", 100, "nonce");
let b = sign("sec", "id", 100, "nonce");
assert_eq!(a, b);
}
#[test]
fn sign_changes_with_different_inputs() {
let a = sign("sec", "id", 100, "nonce1");
let b = sign("sec", "id", 100, "nonce2");
assert_ne!(a, b);
}
#[test]
fn bind_source_serializes_as_number() {
let json = serde_json::to_string(&BindSource::Interactive).unwrap();
assert_eq!(json, "1", "Expected number 1, got: {json}");
let json = serde_json::to_string(&BindSource::Qrcode).unwrap();
assert_eq!(json, "2", "Expected number 2, got: {json}");
}
#[test]
fn fetch_auth_request_includes_required_fields() {
let bot = BotCredential::new("b".into(), "s".into());
let req = FetchAuthRequest::build(&bot, BindSource::Interactive).unwrap();
let json = serde_json::to_value(&req).unwrap();
assert!(json.get("bind_source").is_some());
assert!(json.get("bot_id").is_some());
}
}