wechat-backend-auth 0.1.0

A stateless WeChat OAuth client for backend API developers
Documentation

wechat-backend-auth

Crates.io Documentation License Build Status

一个专为后端开发者设计的完全无状态的微信授权客户端,用于处理"前端传code,后端验证"的授权场景。

特点

  • 完全无状态: 不缓存token、不管理refresh_token、不管理会话
  • 极简接口: 仅封装微信API调用,没有复杂的Manager层
  • 后端全权管理: token存储、会话管理完全由业务代码决定
  • 通用性: 支持公众号、开放平台、移动应用的code验证
  • 类型安全: 使用newtype模式保护敏感信息
  • 完善的错误处理: 清晰的错误类型和自动重试机制

安装

Cargo.toml 中添加:

[dependencies]
wechat-backend-auth = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

快速开始

最简单的示例

use wechat_backend_auth::*;

#[tokio::main]
async fn main() -> Result<(), WeChatError> {
    // 初始化客户端(注意:无需storage参数)
    let client = BackendAuthClient::new(
        BackendConfig::builder()
            .app_id(AppId::new("wx1234567890abcdef"))
            .app_secret(AppSecret::new("your_app_secret_here"))
            .build()
    )?;

    // 前端传来的code
    let code = "front_end_code_123";

    // 换取token
    let token_resp = client
        .exchange_code(AuthorizationCode::new(code))
        .await?;

    // 获取用户信息
    let user_info = client
        .get_user_info(&token_resp.access_token, &token_resp.openid)
        .await?;

    println!("用户登录: {} ({})", user_info.nickname, user_info.openid);
    println!("Token有效期: {:?}", token_resp.expires_in);

    Ok(())
}

使用场景

场景1: RESTful API (Axum框架)

use axum::{
    extract::{Json, State},
    http::StatusCode,
    response::IntoResponse,
    routing::post,
    Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use wechat_backend_auth::*;

#[derive(Deserialize)]
struct LoginRequest {
    code: String,
}

#[derive(Serialize)]
struct LoginResponse {
    token: String,
    user: UserData,
}

#[derive(Serialize)]
struct UserData {
    openid: String,
    nickname: String,
    avatar: String,
}

struct AppState {
    wechat_client: BackendAuthClient,
}

async fn login_handler(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
    // 1. 换取token
    let token_resp = state
        .wechat_client
        .exchange_code(AuthorizationCode::new(payload.code))
        .await
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

    // 2. 获取用户信息
    let user_info = state
        .wechat_client
        .get_user_info(&token_resp.access_token, &token_resp.openid)
        .await
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

    // 3. 创建JWT会话(你的业务逻辑)
    let jwt_token = create_jwt_token(&user_info.openid)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(LoginResponse {
        token: jwt_token,
        user: UserData {
            openid: user_info.openid.as_str().to_string(),
            nickname: user_info.nickname,
            avatar: user_info.headimgurl,
        },
    }))
}

#[tokio::main]
async fn main() {
    let wechat_client = BackendAuthClient::new(
        BackendConfig::builder()
            .app_id(AppId::new("wx_app_id"))
            .app_secret(AppSecret::new("secret"))
            .build()
    ).unwrap();

    let state = Arc::new(AppState { wechat_client });

    let app = Router::new()
        .route("/api/auth/login", post(login_handler))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

fn create_jwt_token(openid: &OpenId) -> Result<String, Box<dyn std::error::Error>> {
    // 实现JWT生成逻辑
    Ok(format!("jwt_token_for_{}", openid.as_str()))
}

场景2: 存储Token到Redis

use redis::AsyncCommands;
use wechat_backend_auth::*;

async fn login_with_redis(
    wechat_client: &BackendAuthClient,
    redis_client: &redis::Client,
    code: String,
) -> Result<String, Box<dyn std::error::Error>> {
    // 1. 换取token
    let token_resp = wechat_client
        .exchange_code(AuthorizationCode::new(code))
        .await?;

    // 2. 获取用户信息
    let user_info = wechat_client
        .get_user_info(&token_resp.access_token, &token_resp.openid)
        .await?;

    // 3. 存储access_token到Redis(2小时过期)
    let mut conn = redis_client.get_async_connection().await?;
    conn.set_ex(
        format!("wechat:access_token:{}", token_resp.openid.as_str()),
        token_resp.access_token.expose_secret(),
        token_resp.expires_in.as_secs() as usize,
    )
    .await?;

    // 4. 存储refresh_token到Redis(30天过期)
    conn.set_ex(
        format!("wechat:refresh_token:{}", token_resp.openid.as_str()),
        token_resp.refresh_token.expose_secret(),
        30 * 24 * 3600, // 30天
    )
    .await?;

    // 5. 创建业务会话
    let session_token = create_session(&user_info.openid)?;

    Ok(session_token)
}

fn create_session(openid: &OpenId) -> Result<String, Box<dyn std::error::Error>> {
    Ok(format!("session_{}", openid.as_str()))
}

场景3: Token刷新

use wechat_backend_auth::*;

async fn refresh_access_token(
    client: &BackendAuthClient,
    old_refresh_token: &str,
) -> Result<TokenResponse, WeChatError> {
    // 使用refresh_token获取新的access_token
    let new_token = client
        .refresh_token(&RefreshToken::new(old_refresh_token))
        .await?;

    // 更新数据库或缓存
    // db.update_token(&new_token.openid, &new_token).await?;

    Ok(new_token)
}

场景4: Token验证

use wechat_backend_auth::*;

async fn validate_user_token(
    client: &BackendAuthClient,
    access_token: String,
    openid: String,
) -> Result<bool, WeChatError> {
    let is_valid = client
        .validate_token(
            &AccessToken::new(access_token),
            &OpenId::new(openid),
        )
        .await?;

    if is_valid {
        println!("✅ Token有效");
    } else {
        println!("❌ Token无效,需要刷新或重新授权");
    }

    Ok(is_valid)
}

API文档

BackendAuthClient

主要方法:

new(config: BackendConfig) -> Result<Self, WeChatError>

创建新的后端授权客户端。

exchange_code(code: AuthorizationCode) -> Result<TokenResponse, WeChatError>

使用授权码换取访问令牌。

get_user_info(access_token: &AccessToken, openid: &OpenId) -> Result<UserInfo, WeChatError>

获取用户详细信息。

validate_token(access_token: &AccessToken, openid: &OpenId) -> Result<bool, WeChatError>

验证访问令牌是否有效。

refresh_token(refresh_token: &RefreshToken) -> Result<TokenResponse, WeChatError>

刷新访问令牌。

配置选项

BackendConfig

let config = BackendConfig::builder()
    .app_id(AppId::new("wx_app_id"))
    .app_secret(AppSecret::new("secret"))
    .http(
        HttpConfig::builder()
            .timeout(Duration::from_secs(30))
            .connect_timeout(Duration::from_secs(10))
            .max_retries(3)
            .build()
    )
    .build();

HttpConfig选项

  • timeout: 请求超时时间(默认30秒)
  • connect_timeout: 连接超时时间(默认10秒)
  • max_retries: 最大重试次数(默认3次)
  • retry_delay: 重试延迟(默认1秒)
  • user_agent: 用户代理字符串

错误处理

use wechat_backend_auth::*;

async fn handle_errors(client: &BackendAuthClient, code: String) {
    match client.exchange_code(AuthorizationCode::new(code)).await {
        Ok(token) => {
            println!("成功: {:?}", token);
        }
        Err(WeChatError::InvalidCode { code, msg }) => {
            eprintln!("无效的授权码 ({}): {}", code, msg);
        }
        Err(WeChatError::CodeUsed) => {
            eprintln!("授权码已被使用");
        }
        Err(WeChatError::AccessTokenExpired { code }) => {
            eprintln!("Token已过期: {}", code);
        }
        Err(WeChatError::Transport(e)) => {
            eprintln!("网络错误: {}", e);
        }
        Err(e) => {
            eprintln!("其他错误: {}", e);
        }
    }
}

安全建议

  1. 保护AppSecret: 使用环境变量,不要硬编码
  2. 使用HTTPS: 生产环境必须使用HTTPS
  3. Token保护: 库已使用SecretString包装敏感信息
  4. 会话管理: 使用JWT等成熟的会话管理方案
// 推荐: 从环境变量读取配置
let client = BackendAuthClient::new(
    BackendConfig::builder()
        .app_id(AppId::new(std::env::var("WECHAT_APP_ID")?))
        .app_secret(AppSecret::new(std::env::var("WECHAT_APP_SECRET")?))
        .build()
)?;

测试

# 运行所有测试
cargo test

# 运行集成测试(需要真实的微信凭证)
export WECHAT_APP_ID="your_app_id"
export WECHAT_APP_SECRET="your_app_secret"
export WECHAT_TEST_CODE="valid_code_from_wechat"
cargo test --test integration_test -- --ignored

常见问题

Q: 为什么去掉TokenManager?

A: 后端场景下,token的管理策略因业务而异。自动管理反而限制了灵活性,不如交给开发者自行决定。

Q: token应该存哪里?

A: 取决于业务需求:

  • Redis: 快速、支持TTL - 适合高并发API
  • 数据库: 持久化、易查询 - 适合需要长期存储
  • 前端存储: 后端无状态 - 适合简单应用
  • 不存储: 最简单 - 适合一次性验证

推荐: Redis(access_token) + 数据库(refresh_token)

Q: 如何处理token过期?

A: 三种策略:

  1. 主动刷新(定时任务)
  2. 被动刷新(调用失败时)
  3. 重新授权(refresh_token也过期)

License

MIT OR Apache-2.0

贡献

欢迎提交Issue和Pull Request!