feat: add wechat miniprogram webview login

This commit is contained in:
2026-05-03 19:05:45 +08:00
parent 9baa515a75
commit ce98a29c4d
17 changed files with 758 additions and 42 deletions

View File

@@ -42,6 +42,8 @@ pub const DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT: &str =
pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/sns/oauth2/access_token";
pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo";
pub const DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT: &str =
"https://api.weixin.qq.com/sns/jscode2session";
type HmacSha1 = Hmac<Sha1>;
@@ -178,9 +180,12 @@ pub struct WechatAuthConfig {
pub provider: String,
pub app_id: Option<String>,
pub app_secret: Option<String>,
pub mini_program_app_id: Option<String>,
pub mini_program_app_secret: Option<String>,
pub authorize_endpoint: String,
pub access_token_endpoint: String,
pub user_info_endpoint: String,
pub js_code_session_endpoint: String,
pub mock_user_id: String,
pub mock_union_id: Option<String>,
pub mock_display_name: String,
@@ -213,11 +218,14 @@ pub struct MockWechatProvider {
#[derive(Clone, Debug)]
pub struct RealWechatProvider {
client: Client,
app_id: String,
app_secret: String,
app_id: Option<String>,
app_secret: Option<String>,
mini_program_app_id: Option<String>,
mini_program_app_secret: Option<String>,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
js_code_session_endpoint: String,
}
#[derive(Clone, Debug)]
@@ -311,6 +319,14 @@ struct WechatUserInfoResponse {
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatJsCodeSessionResponse {
openid: Option<String>,
unionid: Option<String>,
errcode: Option<i64>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AliyunSendSmsVerifyCodeResponse {
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
@@ -628,9 +644,12 @@ impl WechatAuthConfig {
provider: String,
app_id: Option<String>,
app_secret: Option<String>,
mini_program_app_id: Option<String>,
mini_program_app_secret: Option<String>,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
js_code_session_endpoint: String,
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
@@ -641,9 +660,12 @@ impl WechatAuthConfig {
provider,
app_id,
app_secret,
mini_program_app_id,
mini_program_app_secret,
authorize_endpoint,
access_token_endpoint,
user_info_endpoint,
js_code_session_endpoint,
mock_user_id,
mock_union_id,
mock_display_name,
@@ -667,20 +689,36 @@ impl WechatProvider {
});
}
let Some(app_id) = config.app_id else {
return Self::Disabled;
};
let Some(app_secret) = config.app_secret else {
let has_web_oauth_config = config
.app_id
.as_ref()
.is_some_and(|value| !value.is_empty())
&& config
.app_secret
.as_ref()
.is_some_and(|value| !value.is_empty());
let has_mini_program_config = config
.mini_program_app_id
.as_ref()
.is_some_and(|value| !value.is_empty())
&& config
.mini_program_app_secret
.as_ref()
.is_some_and(|value| !value.is_empty());
if !has_web_oauth_config && !has_mini_program_config {
return Self::Disabled;
};
Self::Real(RealWechatProvider {
client: Client::new(),
app_id,
app_secret,
app_id: config.app_id,
app_secret: config.app_secret,
mini_program_app_id: config.mini_program_app_id,
mini_program_app_secret: config.mini_program_app_secret,
authorize_endpoint: config.authorize_endpoint,
access_token_endpoint: config.access_token_endpoint,
user_info_endpoint: config.user_info_endpoint,
js_code_session_endpoint: config.js_code_session_endpoint,
})
}
@@ -708,6 +746,17 @@ impl WechatProvider {
Self::Real(provider) => provider.resolve_callback_profile(code).await,
}
}
pub async fn resolve_mini_program_login_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, WechatProviderError> {
match self {
Self::Disabled => Err(WechatProviderError::Disabled),
Self::Mock(provider) => Ok(provider.resolve_callback_profile(code)),
Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await,
}
}
}
impl MockWechatProvider {
@@ -740,8 +789,11 @@ impl RealWechatProvider {
let mut url = Url::parse(endpoint).map_err(|error| {
WechatProviderError::InvalidConfig(format!("微信授权地址非法:{error}"))
})?;
let app_id = self.app_id.as_ref().ok_or_else(|| {
WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string())
})?;
url.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("appid", app_id)
.append_pair("redirect_uri", callback_url)
.append_pair("response_type", "code")
.append_pair(
@@ -764,13 +816,19 @@ impl RealWechatProvider {
.filter(|value| !value.is_empty())
.ok_or(WechatProviderError::MissingCode)?;
let app_id = self.app_id.as_ref().ok_or_else(|| {
WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string())
})?;
let app_secret = self.app_secret.as_ref().ok_or_else(|| {
WechatProviderError::InvalidConfig("微信开放平台 AppSecret 未配置".to_string())
})?;
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}"))
})?;
access_token_url
.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("secret", &self.app_secret)
.append_pair("appid", app_id)
.append_pair("secret", app_secret)
.append_pair("code", code)
.append_pair("grant_type", "authorization_code");
@@ -856,6 +914,84 @@ impl RealWechatProvider {
avatar_url: user_info_payload.headimgurl,
})
}
async fn resolve_mini_program_login_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, WechatProviderError> {
let code = code
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(WechatProviderError::MissingCode)?;
let app_id = self
.mini_program_app_id
.as_ref()
.or(self.app_id.as_ref())
.ok_or_else(|| {
WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string())
})?;
let app_secret = self
.mini_program_app_secret
.as_ref()
.or(self.app_secret.as_ref())
.ok_or_else(|| {
WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string())
})?;
let mut js_code_session_url =
Url::parse(&self.js_code_session_endpoint).map_err(|error| {
WechatProviderError::InvalidConfig(format!("微信 jscode2session 地址非法:{error}"))
})?;
js_code_session_url
.query_pairs_mut()
.append_pair("appid", app_id)
.append_pair("secret", app_secret)
.append_pair("js_code", code)
.append_pair("grant_type", "authorization_code");
let payload = self
.client
.get(js_code_session_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信小程序 jscode2session 请求失败");
WechatProviderError::RequestFailed(
"微信小程序登录失败jscode2session 请求失败".to_string(),
)
})?
.json::<WechatJsCodeSessionResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信小程序 jscode2session 响应解析失败");
WechatProviderError::DeserializeFailed(
"微信小程序登录失败jscode2session 响应非法".to_string(),
)
})?;
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
return Err(WechatProviderError::Upstream(format!(
"微信小程序登录失败:{}",
payload
.errmsg
.unwrap_or_else(|| format!("jscode2session 返回错误 {errcode}"))
)));
}
let provider_uid = payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
WechatProviderError::MissingProfile("微信小程序登录失败:缺少 openid".to_string())
})?;
Ok(WechatIdentityProfile {
provider_uid,
provider_union_id: payload.unionid,
display_name: None,
avatar_url: None,
})
}
}
fn build_mock_wechat_authorization_url(
@@ -1723,9 +1859,12 @@ mod tests {
"mock".to_string(),
None,
None,
None,
None,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(),
"wx-user-001".to_string(),
Some("wx-union-001".to_string()),
"微信测试用户".to_string(),
@@ -1751,9 +1890,12 @@ mod tests {
"mock".to_string(),
None,
None,
None,
None,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(),
"wx-user-001".to_string(),
Some("wx-union-001".to_string()),
"微信测试用户".to_string(),