feat(auth): WeChat scan login (#228)
Adds the WeChat Open Platform qrconnect flow mirroring QQ Connect: GET /session/wechat/login redirects to the scan page; the callback exchanges the code at sns/oauth2/access_token and binds by unionid (openid fallback). Shares the SSO state/ticket machinery and sso_binding table; provisioned accounts use synthetic @connect.wechat.local addresses. Admin gets a WeChat accordion in UserSession; login and security pages get WeChat buttons. Upstream #2729 item 2. Generated with Devinpull/3589/head
parent
1b9a6850ef
commit
c7a1b7f13d
@ -0,0 +1,104 @@
|
|||||||
|
import { ExpandMoreRounded } from "@mui/icons-material";
|
||||||
|
import { AccordionDetails, FormControl, FormControlLabel, Switch, Typography } from "@mui/material";
|
||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { isTrueVal } from "../../../../session/utils.ts";
|
||||||
|
import { Code } from "../../../Common/Code.tsx";
|
||||||
|
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
|
||||||
|
import { NoMarginHelperText, SettingSectionContent } from "../Settings.tsx";
|
||||||
|
import { SettingContext } from "../SettingWrapper.tsx";
|
||||||
|
import { AccordionSummary, StyledAccordion } from "./SSOSettings.tsx";
|
||||||
|
|
||||||
|
const WeChatConnectSettings = () => {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const { setSettings, values } = useContext(SettingContext);
|
||||||
|
|
||||||
|
const callbackURL = useMemo(() => {
|
||||||
|
const primary = (values.siteURL ?? "").split(",")[0]?.trim().replace(/\/+$/, "");
|
||||||
|
return primary ? `${primary}/api/v4/session/wechat/callback` : "";
|
||||||
|
}, [values.siteURL]);
|
||||||
|
|
||||||
|
const enabled = isTrueVal(values.wechat_connect_enabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledAccordion disableGutters>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings({
|
||||||
|
wechat_connect_enabled: e.target.checked ? "1" : "0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("settings.wechatConnect")}
|
||||||
|
/>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ display: "block" }}>
|
||||||
|
<SettingSectionContent>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<DenseFilledTextField
|
||||||
|
label={t("settings.wechatAppID")}
|
||||||
|
value={values.wechat_connect_app_id}
|
||||||
|
onChange={(e) => setSettings({ wechat_connect_app_id: e.target.value })}
|
||||||
|
required={enabled}
|
||||||
|
/>
|
||||||
|
<NoMarginHelperText>
|
||||||
|
<Trans i18nKey="settings.wechatAppIDDes" ns="dashboard" components={[<Code key="0" />]} />
|
||||||
|
</NoMarginHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<DenseFilledTextField
|
||||||
|
label={t("settings.wechatAppSecret")}
|
||||||
|
value={values.wechat_connect_app_secret ?? ""}
|
||||||
|
onChange={(e) => setSettings({ wechat_connect_app_secret: e.target.value })}
|
||||||
|
type="password"
|
||||||
|
placeholder={t("oauth.secretRedactedPlaceholder")}
|
||||||
|
/>
|
||||||
|
<NoMarginHelperText>{t("oauth.clientSecretDesExisting")}</NoMarginHelperText>
|
||||||
|
</FormControl>
|
||||||
|
{callbackURL && (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<DenseFilledTextField
|
||||||
|
label={t("settings.wechatCallbackUrl")}
|
||||||
|
value={callbackURL}
|
||||||
|
slotProps={{ input: { readOnly: true } }}
|
||||||
|
/>
|
||||||
|
<NoMarginHelperText>
|
||||||
|
<Trans
|
||||||
|
i18nKey="settings.wechatCallbackUrlDes"
|
||||||
|
ns="dashboard"
|
||||||
|
values={{ url: callbackURL }}
|
||||||
|
components={[<Code key="0" />]}
|
||||||
|
/>
|
||||||
|
</NoMarginHelperText>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={isTrueVal(values.wechat_connect_register_enabled)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings({
|
||||||
|
wechat_connect_register_enabled: e.target.checked ? "1" : "0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{t("settings.wechatRegisterEnabled")}</Typography>}
|
||||||
|
/>
|
||||||
|
<NoMarginHelperText>{t("settings.wechatRegisterEnabledDes")}</NoMarginHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</SettingSectionContent>
|
||||||
|
</AccordionDetails>
|
||||||
|
</StyledAccordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WeChatConnectSettings;
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Button, ButtonProps } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ApiPrefix } from "../../../../api/request.ts";
|
||||||
|
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||||
|
import { useQuery } from "../../../../util";
|
||||||
|
|
||||||
|
export default function WeChatLoginButton(props: ButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = useQuery();
|
||||||
|
const { wechat_connect_enabled } = useAppSelector((state) => state.siteConfig.login.config);
|
||||||
|
|
||||||
|
if (!wechat_connect_enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLogin = () => {
|
||||||
|
const redirect = query.get("redirect");
|
||||||
|
const target = new URL(ApiPrefix + "/session/wechat/login", window.location.origin);
|
||||||
|
if (redirect) {
|
||||||
|
target.searchParams.set("redirect", redirect);
|
||||||
|
}
|
||||||
|
window.location.assign(target.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button fullWidth variant="outlined" startIcon={<Icon icon="ri:wechat-fill" />} onClick={startLogin} {...props}>
|
||||||
|
{t("login.signInWith", { name: "WeChat" })}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,347 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/user"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/request"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// WeChatLoginParameterCtx marks the WeChat scan-login start route.
|
||||||
|
WeChatLoginParameterCtx struct{}
|
||||||
|
// WeChatCallbackParameterCtx marks the WeChat redirect target.
|
||||||
|
WeChatCallbackParameterCtx struct{}
|
||||||
|
|
||||||
|
// WeChatLoginService starts the WeChat Open Platform scan flow.
|
||||||
|
// `link=1` binds the WeChat identity to the currently signed-in user
|
||||||
|
// instead of signing in.
|
||||||
|
WeChatLoginService struct {
|
||||||
|
Redirect string `form:"redirect"`
|
||||||
|
Link bool `form:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeChatCallbackService completes the WeChat flow.
|
||||||
|
WeChatCallbackService struct {
|
||||||
|
Code string `form:"code"`
|
||||||
|
State string `form:"state"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wechatAuthorizeEndpoint = "https://open.weixin.qq.com/connect/qrconnect"
|
||||||
|
wechatTokenEndpoint = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||||
|
wechatUserInfoEndpoint = "https://api.weixin.qq.com/sns/userinfo"
|
||||||
|
// wechatConnectMailDomain is the synthetic domain used for provisioned
|
||||||
|
// accounts; WeChat exposes no email claim so a real address cannot exist.
|
||||||
|
wechatConnectMailDomain = "connect.wechat.local"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wechatTokenResponse is the JSON payload of /sns/oauth2/access_token.
|
||||||
|
// unionid is the stable identity across the operator's WeChat apps and is
|
||||||
|
// preferred over openid when present.
|
||||||
|
type wechatTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
OpenID string `json:"openid"`
|
||||||
|
UnionID string `json:"unionid"`
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wechatUserInfoResponse is the profile document from /sns/userinfo.
|
||||||
|
type wechatUserInfoResponse struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWeChatConfig(wx *setting.WeChatConnect) error {
|
||||||
|
if !wx.Enabled || wx.AppID == "" || wx.AppSecret == "" {
|
||||||
|
return errors.New("wechat connect not enabled or not configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wechatCallbackURL(settings setting.Provider, c *gin.Context) string {
|
||||||
|
return settings.SiteURL(c).ResolveReference(&url.URL{Path: "api/v4/session/wechat/callback"}).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login redirects the browser to the WeChat scan QR page.
|
||||||
|
func (service *WeChatLoginService) Login(c *gin.Context) {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
settings := dep.SettingProvider()
|
||||||
|
wx := settings.WeChatConnect(c)
|
||||||
|
|
||||||
|
if err := validateWeChatConfig(wx); err != nil {
|
||||||
|
redirectToSigninWithError(c, settings, "sso_not_configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := ssoState{
|
||||||
|
Redirect: sanitizeSSORedirect(service.Redirect),
|
||||||
|
}
|
||||||
|
if service.Link {
|
||||||
|
u := inventory.UserFromContext(c)
|
||||||
|
if inventory.IsAnonymousUser(u) {
|
||||||
|
redirectToSigninWithError(c, settings, "sso_state_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.LinkUserID = u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
stateKey := ssoStateKey()
|
||||||
|
if err := dep.KV().Set(stateKey, state, ssoStateTTL); err != nil {
|
||||||
|
dep.Logger().Warning("Failed to persist WeChat state: %s", err)
|
||||||
|
redirectToSigninWithError(c, settings, "sso_state_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize, _ := url.Parse(wechatAuthorizeEndpoint)
|
||||||
|
q := authorize.Query()
|
||||||
|
q.Set("appid", wx.AppID)
|
||||||
|
q.Set("redirect_uri", wechatCallbackURL(settings, c))
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("scope", "snsapi_login")
|
||||||
|
q.Set("state", stateKey)
|
||||||
|
authorize.RawQuery = q.Encode()
|
||||||
|
// The WeChat qrconnect page requires the trailing #wechat_redirect
|
||||||
|
// fragment; without it the QR code is not rendered.
|
||||||
|
authorize.Fragment = "wechat_redirect"
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, authorize.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback exchanges the code, resolves the WeChat unionid/openid, then
|
||||||
|
// either binds it to the linking user or signs the bound account in.
|
||||||
|
func (service *WeChatCallbackService) Callback(c *gin.Context) {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
settings := dep.SettingProvider()
|
||||||
|
|
||||||
|
fail := func(code string) {
|
||||||
|
redirectToSigninWithError(c, settings, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
wx := settings.WeChatConnect(c)
|
||||||
|
if err := validateWeChatConfig(wx); err != nil {
|
||||||
|
fail("sso_not_configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.State == "" || service.Code == "" {
|
||||||
|
fail("sso_invalid_response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawState, ok := dep.KV().Get(service.State)
|
||||||
|
if !ok {
|
||||||
|
fail("sso_state_expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = dep.KV().Delete("", service.State)
|
||||||
|
state, ok := rawState.(ssoState)
|
||||||
|
if !ok {
|
||||||
|
fail("sso_state_expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The endpoints are constants, but DNS resolution is not: reject a
|
||||||
|
// weixin host that resolves to a private address before issuing
|
||||||
|
// credentialed requests.
|
||||||
|
for _, endpoint := range []string{wechatTokenEndpoint, wechatUserInfoEndpoint} {
|
||||||
|
if err := request.ValidateExternalURL(c, endpoint, request.SSRFOptions{}); err != nil {
|
||||||
|
dep.Logger().Warning("WeChat endpoint rejected by SSRF check: %s", err)
|
||||||
|
fail("sso_exchange_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := dep.RequestClient()
|
||||||
|
token, err := wechatExchangeCode(c, httpClient, service.Code, wx)
|
||||||
|
if err != nil {
|
||||||
|
dep.Logger().Warning("WeChat token exchange failed: %s", err)
|
||||||
|
fail("sso_exchange_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer unionid: it identifies the same person across every app in the
|
||||||
|
// operator's WeChat open-platform account.
|
||||||
|
identity := token.UnionID
|
||||||
|
if identity == "" {
|
||||||
|
identity = token.OpenID
|
||||||
|
}
|
||||||
|
if identity == "" {
|
||||||
|
fail("sso_token_invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings := dep.SsoBindingClient()
|
||||||
|
|
||||||
|
// Link mode: attach the identity to the signed-in user and return to the
|
||||||
|
// security settings tab.
|
||||||
|
if state.LinkUserID != 0 {
|
||||||
|
if _, err := bindings.Bind(c, state.LinkUserID, inventory.SsoProviderWeChat, identity); err != nil {
|
||||||
|
dep.Logger().Info("WeChat link rejected: %s", err)
|
||||||
|
if errors.Is(err, inventory.ErrSsoBindingConflict) {
|
||||||
|
fail("sso_account_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail("sso_state_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordUserEvent(c, dep, state.LinkUserID, types.EventLinkAccount, map[string]any{"provider": inventory.SsoProviderWeChat})
|
||||||
|
dest := settings.SiteURL(c).ResolveReference(&url.URL{Path: "settings", RawQuery: "tab=security"})
|
||||||
|
c.Redirect(http.StatusFound, dest.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetUser *ent.User
|
||||||
|
binding, err := bindings.Get(c, inventory.SsoProviderWeChat, identity)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
|
||||||
|
targetUser, err = dep.UserClient().GetByID(ctx, binding.UserID)
|
||||||
|
if err != nil {
|
||||||
|
dep.Logger().Warning("WeChat binding resolved to missing user %d: %s", binding.UserID, err)
|
||||||
|
fail("sso_account_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case ent.IsNotFound(err):
|
||||||
|
if !wx.RegisterEnabled {
|
||||||
|
dep.Logger().Info("WeChat login rejected: provisioning disabled")
|
||||||
|
fail("sso_account_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetUser, err = wechatProvisionUser(c, dep, httpClient, token, wx)
|
||||||
|
if err != nil {
|
||||||
|
dep.Logger().Warning("WeChat provisioning failed: %s", err)
|
||||||
|
fail("sso_account_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := bindings.Bind(c, targetUser.ID, inventory.SsoProviderWeChat, identity); err != nil {
|
||||||
|
dep.Logger().Warning("WeChat binding failed: %s", err)
|
||||||
|
fail("sso_state_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
dep.Logger().Warning("WeChat binding lookup failed: %s", err)
|
||||||
|
fail("sso_state_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetUser, err = dep.UserClient().LiftExpiredBan(c, targetUser); err != nil {
|
||||||
|
fail("sso_account_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := checkUserStatus(c, targetUser); err != nil {
|
||||||
|
dep.Logger().Info("WeChat login rejected: %s", err)
|
||||||
|
fail("sso_account_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket := ssoTicketKey()
|
||||||
|
if err := dep.KV().Set(ticket, targetUser.ID, ssoTicketTTL); err != nil {
|
||||||
|
fail("sso_state_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback := settings.SiteURL(c).ResolveReference(&url.URL{Path: "callback/sso"})
|
||||||
|
q := callback.Query()
|
||||||
|
q.Set("ticket", ticket)
|
||||||
|
if state.Redirect != "" {
|
||||||
|
q.Set("redirect", state.Redirect)
|
||||||
|
}
|
||||||
|
callback.RawQuery = q.Encode()
|
||||||
|
c.Redirect(http.StatusFound, callback.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wechatExchangeCode trades the authorization code for an access token.
|
||||||
|
// Unlike QQ, WeChat answers with JSON; the token response already carries
|
||||||
|
// openid and unionid.
|
||||||
|
func wechatExchangeCode(c *gin.Context, client request.Client, code string, wx *setting.WeChatConnect) (*wechatTokenResponse, error) {
|
||||||
|
endpoint, _ := url.Parse(wechatTokenEndpoint)
|
||||||
|
q := endpoint.Query()
|
||||||
|
q.Set("appid", wx.AppID)
|
||||||
|
q.Set("secret", wx.AppSecret)
|
||||||
|
q.Set("code", code)
|
||||||
|
q.Set("grant_type", "authorization_code")
|
||||||
|
endpoint.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
resp, err := client.
|
||||||
|
Request(http.MethodGet, endpoint.String(), nil,
|
||||||
|
request.WithContext(c),
|
||||||
|
request.WithTimeout(15*time.Second),
|
||||||
|
).
|
||||||
|
CheckHTTPResponse(http.StatusOK).
|
||||||
|
GetResponse()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseWeChatToken(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWeChatToken unwraps the JSON token reply. WeChat reports failures as
|
||||||
|
// HTTP 200 with a nonzero errcode, so both paths must be checked.
|
||||||
|
func parseWeChatToken(body string) (*wechatTokenResponse, error) {
|
||||||
|
var token wechatTokenResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &token); err != nil {
|
||||||
|
return nil, fmt.Errorf("malformed token response: %w", err)
|
||||||
|
}
|
||||||
|
if token.ErrCode != 0 || token.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("token error %d: %s", token.ErrCode, token.ErrMsg)
|
||||||
|
}
|
||||||
|
return &token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wechatProvisionUser creates the local account for a new WeChat identity.
|
||||||
|
// WeChat has no email claim, so a synthetic address under
|
||||||
|
// connect.wechat.local is used; the nickname comes from userinfo and falls
|
||||||
|
// back to an openid suffix.
|
||||||
|
func wechatProvisionUser(c *gin.Context, dep dependency.Dep, client request.Client, token *wechatTokenResponse, wx *setting.WeChatConnect) (*ent.User, error) {
|
||||||
|
nick := ""
|
||||||
|
endpoint, _ := url.Parse(wechatUserInfoEndpoint)
|
||||||
|
q := endpoint.Query()
|
||||||
|
q.Set("access_token", token.AccessToken)
|
||||||
|
q.Set("openid", token.OpenID)
|
||||||
|
endpoint.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
if resp, err := client.
|
||||||
|
Request(http.MethodGet, endpoint.String(), nil,
|
||||||
|
request.WithContext(c),
|
||||||
|
request.WithTimeout(10*time.Second),
|
||||||
|
).
|
||||||
|
CheckHTTPResponse(http.StatusOK).
|
||||||
|
GetResponse(); err == nil {
|
||||||
|
var info wechatUserInfoResponse
|
||||||
|
if err := json.Unmarshal([]byte(resp), &info); err == nil && info.ErrCode == 0 {
|
||||||
|
nick = strings.TrimSpace(info.Nickname)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dep.Logger().Warning("WeChat userinfo request failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nick == "" {
|
||||||
|
nick = "WeChat user " + token.OpenID[len(token.OpenID)-min(6, len(token.OpenID)):]
|
||||||
|
}
|
||||||
|
if len(nick) > 100 {
|
||||||
|
nick = nick[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
return dep.UserClient().Create(c, &inventory.NewUserArgs{
|
||||||
|
Email: fmt.Sprintf("wx_%s@%s", token.OpenID, wechatConnectMailDomain),
|
||||||
|
Nick: nick,
|
||||||
|
Status: user.StatusActive,
|
||||||
|
GroupID: dep.SettingProvider().DefaultGroup(c),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWeChatToken(t *testing.T) {
|
||||||
|
token, err := parseWeChatToken(`{"access_token":"AT","openid":"o","unionid":"u"}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "AT", token.AccessToken)
|
||||||
|
require.Equal(t, "u", token.UnionID)
|
||||||
|
|
||||||
|
_, err = parseWeChatToken(`{"errcode":40029,"errmsg":"invalid code"}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = parseWeChatToken(`{"openid":"o"}`) // no access_token
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = parseWeChatToken(`not json`)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue