Files
sub2api/backend/internal/handler/auth_wechat_oauth.go
2026-04-20 18:28:44 +08:00

756 lines
26 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const (
wechatOAuthCookiePath = "/api/v1/auth/oauth/wechat"
wechatOAuthCookieMaxAgeSec = 10 * 60
wechatOAuthStateCookieName = "wechat_oauth_state"
wechatOAuthRedirectCookieName = "wechat_oauth_redirect"
wechatOAuthIntentCookieName = "wechat_oauth_intent"
wechatOAuthModeCookieName = "wechat_oauth_mode"
wechatOAuthBindUserCookieName = "wechat_oauth_bind_user"
wechatOAuthDefaultRedirectTo = "/dashboard"
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
wechatOAuthProviderKey = "wechat-main"
wechatOAuthIntentLogin = "login"
wechatOAuthIntentBind = "bind_current_user"
wechatOAuthIntentAdoptEmail = "adopt_existing_user_by_email"
)
var (
wechatOAuthAccessTokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token"
wechatOAuthUserInfoURL = "https://api.weixin.qq.com/sns/userinfo"
)
type wechatOAuthConfig struct {
mode string
appID string
appSecret string
authorizeURL string
scope string
redirectURI string
frontendCallback string
}
type wechatOAuthTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
OpenID string `json:"openid"`
Scope string `json:"scope"`
UnionID string `json:"unionid"`
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
type wechatOAuthUserInfoResponse struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
HeadImgURL string `json:"headimgurl"`
UnionID string `json:"unionid"`
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
// browser cookies required by the rebuild pending-auth bridge.
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
if err != nil {
response.ErrorFrom(c, err)
return
}
state, err := oauth.GenerateState()
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
return
}
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
if redirectTo == "" {
redirectTo = wechatOAuthDefaultRedirectTo
}
browserSessionKey, err := generateOAuthPendingBrowserSession()
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
return
}
intent := normalizeWeChatOAuthIntent(c.Query("intent"))
secureCookie := isRequestHTTPS(c)
wechatSetCookie(c, wechatOAuthStateCookieName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
wechatSetCookie(c, wechatOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), wechatOAuthCookieMaxAgeSec, secureCookie)
} else {
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
}
authURL, err := buildWeChatAuthorizeURL(cfg, state)
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
return
}
c.Redirect(http.StatusFound, authURL)
}
// WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
// and stores the result in the unified pending-auth flow.
func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
frontendCallback := wechatOAuthFrontendCallback()
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
return
}
code := strings.TrimSpace(c.Query("code"))
state := strings.TrimSpace(c.Query("state"))
if code == "" || state == "" {
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
return
}
secureCookie := isRequestHTTPS(c)
defer func() {
wechatClearCookie(c, wechatOAuthStateCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthRedirectCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
}()
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
if err != nil || expectedState == "" || state != expectedState {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
return
}
redirectTo, _ := readCookieDecoded(c, wechatOAuthRedirectCookieName)
redirectTo = sanitizeFrontendRedirectPath(redirectTo)
if redirectTo == "" {
redirectTo = wechatOAuthDefaultRedirectTo
}
browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
if strings.TrimSpace(browserSessionKey) == "" {
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
return
}
intent, _ := readCookieDecoded(c, wechatOAuthIntentCookieName)
mode, err := readCookieDecoded(c, wechatOAuthModeCookieName)
if err != nil || strings.TrimSpace(mode) == "" {
redirectOAuthError(c, frontendCallback, "invalid_state", "missing oauth mode", "")
return
}
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), mode, c)
if err != nil {
redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
tokenResp, userInfo, err := fetchWeChatOAuthIdentity(c.Request.Context(), cfg, code)
if err != nil {
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_identity_fetch_failed", singleLine(err.Error()))
return
}
unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID))
openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID))
providerSubject := firstNonEmpty(unionid, openid)
if providerSubject == "" {
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_subject", "")
return
}
username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject))
email := wechatSyntheticEmail(providerSubject)
upstreamClaims := map[string]any{
"email": email,
"username": username,
"subject": providerSubject,
"openid": openid,
"unionid": unionid,
"mode": cfg.mode,
"channel": cfg.mode,
"channel_app_id": strings.TrimSpace(cfg.appID),
"channel_subject": openid,
"suggested_display_name": strings.TrimSpace(userInfo.Nickname),
"suggested_avatar_url": strings.TrimSpace(userInfo.HeadImgURL),
}
normalizedIntent := normalizeWeChatOAuthIntent(intent)
if normalizedIntent == wechatOAuthIntentBind {
if err := h.createWeChatBindPendingSession(c, cfg, providerSubject, openid, redirectTo, browserSessionKey, upstreamClaims); err != nil {
switch infraerrors.Code(err) {
case http.StatusConflict:
redirectOAuthError(c, frontendCallback, "ownership_conflict", infraerrors.Reason(err), infraerrors.Message(err))
case http.StatusUnauthorized, http.StatusForbidden:
redirectOAuthError(c, frontendCallback, "auth_required", infraerrors.Reason(err), infraerrors.Message(err))
default:
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
}
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
if err != nil {
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err, nil); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil, nil); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
}
type completeWeChatOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
// CompleteWeChatOAuthRegistration completes a pending WeChat OAuth registration by
// validating the invitation code and consuming the current pending browser session.
// POST /api/v1/auth/oauth/wechat/complete-registration
func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
var req completeWeChatOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
return
}
secureCookie := isRequestHTTPS(c)
sessionToken, err := readOAuthPendingSessionCookie(c)
if err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
return
}
browserSessionKey, err := readOAuthPendingBrowserCookie(c)
if err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
return
}
pendingSvc, err := h.pendingIdentityService()
if err != nil {
response.ErrorFrom(c, err)
return
}
session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
if err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, err)
return
}
email := strings.TrimSpace(session.ResolvedEmail)
username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
if email == "" || username == "" {
response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
if err != nil {
response.ErrorFrom(c, err)
return
}
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
AdoptDisplayName: req.AdoptDisplayName,
AdoptAvatar: req.AdoptAvatar,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), session, decision, &user.ID); err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
return
}
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, err)
return
}
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
c.JSON(http.StatusOK, gin.H{
"access_token": tokenPair.AccessToken,
"refresh_token": tokenPair.RefreshToken,
"expires_in": tokenPair.ExpiresIn,
"token_type": "Bearer",
})
}
func (h *AuthHandler) createWeChatPendingSession(
c *gin.Context,
intent string,
providerSubject string,
email string,
redirectTo string,
browserSessionKey string,
upstreamClaims map[string]any,
tokenPair *service.TokenPair,
authErr error,
targetUserID *int64,
) error {
completionResponse := map[string]any{
"redirect": redirectTo,
}
if authErr != nil {
if errors.Is(authErr, service.ErrOAuthInvitationRequired) {
completionResponse["error"] = "invitation_required"
} else {
return authErr
}
} else if tokenPair != nil {
completionResponse["access_token"] = tokenPair.AccessToken
completionResponse["refresh_token"] = tokenPair.RefreshToken
completionResponse["expires_in"] = tokenPair.ExpiresIn
completionResponse["token_type"] = "Bearer"
}
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: intent,
Identity: service.PendingAuthIdentityKey{
ProviderType: "wechat",
ProviderKey: wechatOAuthProviderKey,
ProviderSubject: providerSubject,
},
TargetUserID: targetUserID,
ResolvedEmail: email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
})
}
func (h *AuthHandler) createWeChatBindPendingSession(
c *gin.Context,
cfg wechatOAuthConfig,
providerSubject string,
channelSubject string,
redirectTo string,
browserSessionKey string,
upstreamClaims map[string]any,
) error {
currentUser, err := h.readOAuthBindTargetUser(c, wechatOAuthBindUserCookieName)
if err != nil {
return err
}
if err := h.ensureWeChatBindOwnership(c.Request.Context(), currentUser.ID, providerSubject, cfg, channelSubject); err != nil {
return err
}
return h.createWeChatPendingSession(
c,
wechatOAuthIntentBind,
providerSubject,
currentUser.Email,
redirectTo,
browserSessionKey,
upstreamClaims,
nil,
nil,
&currentUser.ID,
)
}
func (h *AuthHandler) readOAuthBindTargetUser(c *gin.Context, cookieName string) (*dbent.User, error) {
client := h.entClient()
if client == nil {
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
}
userID, err := h.readOAuthBindUserIDFromCookie(c, cookieName)
if err != nil {
return nil, infraerrors.Unauthorized("AUTH_REQUIRED", "current user is required to bind wechat account")
}
userEntity, err := client.User.Get(c.Request.Context(), userID)
if err != nil {
if dbent.IsNotFound(err) {
return nil, infraerrors.Unauthorized("AUTH_REQUIRED", "current user is required to bind wechat account")
}
return nil, infraerrors.InternalServer("WECHAT_BIND_USER_LOOKUP_FAILED", "failed to load current user").WithCause(err)
}
return userEntity, nil
}
func (h *AuthHandler) ensureWeChatBindOwnership(
ctx context.Context,
userID int64,
providerSubject string,
cfg wechatOAuthConfig,
channelSubject string,
) error {
client := h.entClient()
if client == nil {
return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
}
identity, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("wechat"),
authidentity.ProviderKeyEQ(wechatOAuthProviderKey),
authidentity.ProviderSubjectEQ(strings.TrimSpace(providerSubject)),
).
Only(ctx)
if err != nil && !dbent.IsNotFound(err) {
return infraerrors.InternalServer("WECHAT_BIND_LOOKUP_FAILED", "failed to inspect wechat identity ownership").WithCause(err)
}
if identity != nil && identity.UserID != userID {
return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
}
channelSubject = strings.TrimSpace(channelSubject)
channelAppID := strings.TrimSpace(cfg.appID)
if channelSubject == "" || channelAppID == "" {
return nil
}
channel, err := client.AuthIdentityChannel.Query().
Where(
authidentitychannel.ProviderTypeEQ("wechat"),
authidentitychannel.ProviderKeyEQ(wechatOAuthProviderKey),
authidentitychannel.ChannelEQ(strings.TrimSpace(cfg.mode)),
authidentitychannel.ChannelAppIDEQ(channelAppID),
authidentitychannel.ChannelSubjectEQ(channelSubject),
).
WithIdentity().
Only(ctx)
if err != nil && !dbent.IsNotFound(err) {
return infraerrors.InternalServer("WECHAT_BIND_CHANNEL_LOOKUP_FAILED", "failed to inspect wechat identity channel ownership").WithCause(err)
}
if channel != nil && channel.Edges.Identity != nil && channel.Edges.Identity.UserID != userID {
return infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user")
}
return nil
}
func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, c *gin.Context) (wechatOAuthConfig, error) {
mode, err := resolveWeChatOAuthMode(rawMode, c)
if err != nil {
return wechatOAuthConfig{}, err
}
apiBaseURL := ""
if h != nil && h.settingSvc != nil {
settings, err := h.settingSvc.GetAllSettings(ctx)
if err == nil && settings != nil {
apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
}
}
cfg := wechatOAuthConfig{
mode: mode,
redirectURI: resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback"),
frontendCallback: wechatOAuthFrontendCallback(),
}
switch mode {
case "mp":
cfg.appID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
cfg.appSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
cfg.authorizeURL = "https://open.weixin.qq.com/connect/oauth2/authorize"
cfg.scope = "snsapi_userinfo"
default:
cfg.appID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID"))
cfg.appSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET"))
cfg.authorizeURL = "https://open.weixin.qq.com/connect/qrconnect"
cfg.scope = "snsapi_login"
}
if cfg.appID == "" || cfg.appSecret == "" {
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
}
if strings.TrimSpace(cfg.redirectURI) == "" {
return wechatOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured")
}
return cfg, nil
}
func wechatOAuthFrontendCallback() string {
return firstNonEmpty(strings.TrimSpace(os.Getenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL")), wechatOAuthDefaultFrontendCB)
}
func resolveWeChatOAuthMode(rawMode string, c *gin.Context) (string, error) {
mode := strings.ToLower(strings.TrimSpace(rawMode))
if mode == "" {
if isWeChatBrowserRequest(c) {
return "mp", nil
}
return "open", nil
}
if mode != "open" && mode != "mp" {
return "", infraerrors.BadRequest("INVALID_MODE", "wechat oauth mode must be open or mp")
}
return mode, nil
}
func isWeChatBrowserRequest(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
return strings.Contains(strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent"))), "micromessenger")
}
func normalizeWeChatOAuthIntent(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "login":
return wechatOAuthIntentLogin
case "bind", "bind_current_user":
return wechatOAuthIntentBind
case "adopt", "adopt_existing_user_by_email":
return wechatOAuthIntentAdoptEmail
default:
return wechatOAuthIntentLogin
}
}
func buildWeChatAuthorizeURL(cfg wechatOAuthConfig, state string) (string, error) {
u, err := url.Parse(cfg.authorizeURL)
if err != nil {
return "", fmt.Errorf("parse authorize url: %w", err)
}
query := u.Query()
query.Set("appid", cfg.appID)
query.Set("redirect_uri", cfg.redirectURI)
query.Set("response_type", "code")
query.Set("scope", cfg.scope)
query.Set("state", state)
u.RawQuery = query.Encode()
u.Fragment = "wechat_redirect"
return u.String(), nil
}
func resolveWeChatOAuthAbsoluteURL(apiBaseURL string, c *gin.Context, callbackPath string) string {
callbackPath = strings.TrimSpace(callbackPath)
if callbackPath == "" {
return ""
}
if raw := strings.TrimSpace(apiBaseURL); raw != "" {
if parsed, err := url.Parse(raw); err == nil && parsed.Scheme != "" && parsed.Host != "" {
basePath := strings.TrimRight(parsed.EscapedPath(), "/")
targetPath := callbackPath
if basePath != "" && strings.HasSuffix(basePath, "/api/v1") && strings.HasPrefix(callbackPath, "/api/v1") {
targetPath = basePath + strings.TrimPrefix(callbackPath, "/api/v1")
} else if basePath != "" {
targetPath = basePath + callbackPath
}
return parsed.Scheme + "://" + parsed.Host + targetPath
}
}
if c == nil || c.Request == nil {
return ""
}
scheme := "http"
if isRequestHTTPS(c) {
scheme = "https"
}
host := strings.TrimSpace(c.Request.Host)
if forwardedHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
host = forwardedHost
}
if host == "" {
return ""
}
return scheme + "://" + host + callbackPath
}
func fetchWeChatOAuthIdentity(ctx context.Context, cfg wechatOAuthConfig, code string) (*wechatOAuthTokenResponse, *wechatOAuthUserInfoResponse, error) {
tokenResp, err := exchangeWeChatOAuthCode(ctx, cfg, code)
if err != nil {
return nil, nil, err
}
userInfo, err := fetchWeChatUserInfo(ctx, tokenResp)
if err != nil {
return nil, nil, err
}
return tokenResp, userInfo, nil
}
func exchangeWeChatOAuthCode(ctx context.Context, cfg wechatOAuthConfig, code string) (*wechatOAuthTokenResponse, error) {
endpoint, err := url.Parse(wechatOAuthAccessTokenURL)
if err != nil {
return nil, fmt.Errorf("parse wechat access token url: %w", err)
}
query := endpoint.Query()
query.Set("appid", cfg.appID)
query.Set("secret", cfg.appSecret)
query.Set("code", strings.TrimSpace(code))
query.Set("grant_type", "authorization_code")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("build wechat access token request: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request wechat access token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read wechat access token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("wechat access token status=%d", resp.StatusCode)
}
var tokenResp wechatOAuthTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("decode wechat access token response: %w", err)
}
if tokenResp.ErrCode != 0 {
return nil, fmt.Errorf("wechat access token error=%d %s", tokenResp.ErrCode, strings.TrimSpace(tokenResp.ErrMsg))
}
if strings.TrimSpace(tokenResp.AccessToken) == "" {
return nil, fmt.Errorf("wechat access token missing access_token")
}
return &tokenResp, nil
}
func fetchWeChatUserInfo(ctx context.Context, tokenResp *wechatOAuthTokenResponse) (*wechatOAuthUserInfoResponse, error) {
if tokenResp == nil {
return nil, fmt.Errorf("wechat token response is nil")
}
endpoint, err := url.Parse(wechatOAuthUserInfoURL)
if err != nil {
return nil, fmt.Errorf("parse wechat userinfo url: %w", err)
}
query := endpoint.Query()
query.Set("access_token", strings.TrimSpace(tokenResp.AccessToken))
query.Set("openid", strings.TrimSpace(tokenResp.OpenID))
query.Set("lang", "zh_CN")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("build wechat userinfo request: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request wechat userinfo: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read wechat userinfo response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("wechat userinfo status=%d", resp.StatusCode)
}
var userInfo wechatOAuthUserInfoResponse
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, fmt.Errorf("decode wechat userinfo response: %w", err)
}
if userInfo.ErrCode != 0 {
return nil, fmt.Errorf("wechat userinfo error=%d %s", userInfo.ErrCode, strings.TrimSpace(userInfo.ErrMsg))
}
return &userInfo, nil
}
func wechatSyntheticEmail(subject string) string {
subject = strings.TrimSpace(subject)
if subject == "" {
return ""
}
return "wechat-" + subject + service.WeChatConnectSyntheticEmailDomain
}
func wechatFallbackUsername(subject string) string {
subject = strings.TrimSpace(subject)
if subject == "" {
return "wechat_user"
}
return "wechat_" + truncateFragmentValue(subject)
}
func wechatSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: value,
Path: wechatOAuthCookiePath,
MaxAge: maxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func wechatClearCookie(c *gin.Context, name string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: wechatOAuthCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}