auth_wechat_oauth.go 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "net/url"
  10. "strconv"
  11. "strings"
  12. "time"
  13. dbent "github.com/Wei-Shaw/sub2api/ent"
  14. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  15. "github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
  16. "github.com/Wei-Shaw/sub2api/internal/payment"
  17. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  19. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  20. "github.com/Wei-Shaw/sub2api/internal/service"
  21. "github.com/gin-gonic/gin"
  22. )
  23. const (
  24. wechatOAuthCookiePath = "/api/v1/auth/oauth/wechat"
  25. wechatOAuthCookieMaxAgeSec = 10 * 60
  26. wechatOAuthStateCookieName = "wechat_oauth_state"
  27. wechatOAuthRedirectCookieName = "wechat_oauth_redirect"
  28. wechatOAuthIntentCookieName = "wechat_oauth_intent"
  29. wechatOAuthModeCookieName = "wechat_oauth_mode"
  30. wechatOAuthBindUserCookieName = "wechat_oauth_bind_user"
  31. wechatOAuthDefaultRedirectTo = "/dashboard"
  32. wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
  33. wechatOAuthProviderKey = "wechat-main"
  34. wechatOAuthLegacyProviderKey = "wechat"
  35. wechatPaymentOAuthCookiePath = "/api/v1/auth/oauth/wechat/payment"
  36. wechatPaymentOAuthStateName = "wechat_payment_oauth_state"
  37. wechatPaymentOAuthRedirect = "wechat_payment_oauth_redirect"
  38. wechatPaymentOAuthContextName = "wechat_payment_oauth_context"
  39. wechatPaymentOAuthScope = "wechat_payment_oauth_scope"
  40. wechatPaymentOAuthDefaultTo = "/purchase"
  41. wechatPaymentOAuthFrontendCB = "/auth/wechat/payment/callback"
  42. wechatOAuthIntentLogin = "login"
  43. wechatOAuthIntentBind = "bind_current_user"
  44. wechatOAuthIntentAdoptEmail = "adopt_existing_user_by_email"
  45. )
  46. var (
  47. wechatOAuthAccessTokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token"
  48. wechatOAuthUserInfoURL = "https://api.weixin.qq.com/sns/userinfo"
  49. )
  50. type wechatOAuthConfig struct {
  51. mode string
  52. appID string
  53. appSecret string
  54. authorizeURL string
  55. scope string
  56. redirectURI string
  57. frontendCallback string
  58. openEnabled bool
  59. mpEnabled bool
  60. }
  61. type wechatOAuthTokenResponse struct {
  62. AccessToken string `json:"access_token"`
  63. ExpiresIn int64 `json:"expires_in"`
  64. RefreshToken string `json:"refresh_token"`
  65. OpenID string `json:"openid"`
  66. Scope string `json:"scope"`
  67. UnionID string `json:"unionid"`
  68. ErrCode int64 `json:"errcode"`
  69. ErrMsg string `json:"errmsg"`
  70. }
  71. type wechatOAuthUserInfoResponse struct {
  72. OpenID string `json:"openid"`
  73. Nickname string `json:"nickname"`
  74. HeadImgURL string `json:"headimgurl"`
  75. UnionID string `json:"unionid"`
  76. ErrCode int64 `json:"errcode"`
  77. ErrMsg string `json:"errmsg"`
  78. }
  79. type wechatPaymentOAuthContext struct {
  80. PaymentType string `json:"payment_type"`
  81. Amount string `json:"amount,omitempty"`
  82. OrderType string `json:"order_type,omitempty"`
  83. PlanID int64 `json:"plan_id,omitempty"`
  84. }
  85. // WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
  86. // browser cookies required by the rebuild pending-auth bridge.
  87. func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
  88. if !h.requireActionCaptchaForOAuthLoginStart(c) {
  89. return
  90. }
  91. cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
  92. if err != nil {
  93. response.ErrorFrom(c, err)
  94. return
  95. }
  96. state, err := oauth.GenerateState()
  97. if err != nil {
  98. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
  99. return
  100. }
  101. redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
  102. if redirectTo == "" {
  103. redirectTo = wechatOAuthDefaultRedirectTo
  104. }
  105. browserSessionKey, err := generateOAuthPendingBrowserSession()
  106. if err != nil {
  107. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
  108. return
  109. }
  110. intent := normalizeWeChatOAuthIntent(c.Query("intent"))
  111. secureCookie := isRequestHTTPS(c)
  112. wechatSetCookie(c, wechatOAuthStateCookieName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
  113. wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
  114. wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie)
  115. wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
  116. captureOAuthPromoCode(c, secureCookie)
  117. setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
  118. clearOAuthPendingSessionCookie(c, secureCookie)
  119. if intent == oauthIntentBindCurrentUser {
  120. bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
  121. if err != nil {
  122. response.ErrorFrom(c, err)
  123. return
  124. }
  125. wechatSetCookie(c, wechatOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), wechatOAuthCookieMaxAgeSec, secureCookie)
  126. } else {
  127. wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
  128. }
  129. authURL, err := buildWeChatAuthorizeURL(cfg, state)
  130. if err != nil {
  131. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
  132. return
  133. }
  134. respondOAuthStart(c, authURL)
  135. }
  136. // WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
  137. // and stores the result in the unified pending-auth flow.
  138. func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
  139. frontendCallback := h.wechatOAuthFrontendCallback(c.Request.Context())
  140. if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
  141. redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
  142. return
  143. }
  144. code := strings.TrimSpace(c.Query("code"))
  145. state := strings.TrimSpace(c.Query("state"))
  146. if code == "" || state == "" {
  147. redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
  148. return
  149. }
  150. secureCookie := isRequestHTTPS(c)
  151. defer func() {
  152. wechatClearCookie(c, wechatOAuthStateCookieName, secureCookie)
  153. wechatClearCookie(c, wechatOAuthRedirectCookieName, secureCookie)
  154. wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
  155. wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
  156. wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
  157. clearOAuthPromoCodeCookie(c, secureCookie)
  158. }()
  159. expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
  160. if err != nil || expectedState == "" || state != expectedState {
  161. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
  162. return
  163. }
  164. redirectTo, _ := readCookieDecoded(c, wechatOAuthRedirectCookieName)
  165. redirectTo = sanitizeFrontendRedirectPath(redirectTo)
  166. if redirectTo == "" {
  167. redirectTo = wechatOAuthDefaultRedirectTo
  168. }
  169. browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
  170. if strings.TrimSpace(browserSessionKey) == "" {
  171. redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
  172. return
  173. }
  174. intent, _ := readCookieDecoded(c, wechatOAuthIntentCookieName)
  175. mode, err := readCookieDecoded(c, wechatOAuthModeCookieName)
  176. if err != nil || strings.TrimSpace(mode) == "" {
  177. redirectOAuthError(c, frontendCallback, "invalid_state", "missing oauth mode", "")
  178. return
  179. }
  180. cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), mode, c)
  181. if err != nil {
  182. redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
  183. return
  184. }
  185. tokenResp, userInfo, err := fetchWeChatOAuthIdentity(c.Request.Context(), cfg, code)
  186. if err != nil {
  187. redirectOAuthError(c, frontendCallback, "provider_error", "wechat_identity_fetch_failed", singleLine(err.Error()))
  188. return
  189. }
  190. unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID))
  191. openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID))
  192. providerSubject := unionid
  193. if providerSubject == "" {
  194. if cfg.requiresUnionID() {
  195. redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
  196. return
  197. }
  198. providerSubject = openid
  199. }
  200. if providerSubject == "" {
  201. redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
  202. return
  203. }
  204. username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject))
  205. email := wechatSyntheticEmail(providerSubject)
  206. upstreamClaims := map[string]any{
  207. "email": email,
  208. "username": username,
  209. "subject": providerSubject,
  210. "openid": openid,
  211. "unionid": unionid,
  212. "mode": cfg.mode,
  213. "channel": cfg.mode,
  214. "channel_app_id": strings.TrimSpace(cfg.appID),
  215. "channel_subject": openid,
  216. "suggested_display_name": strings.TrimSpace(userInfo.Nickname),
  217. "suggested_avatar_url": strings.TrimSpace(userInfo.HeadImgURL),
  218. }
  219. identityRef := service.PendingAuthIdentityKey{
  220. ProviderType: "wechat",
  221. ProviderKey: wechatOAuthProviderKey,
  222. ProviderSubject: providerSubject,
  223. }
  224. normalizedIntent := normalizeWeChatOAuthIntent(intent)
  225. if normalizedIntent == wechatOAuthIntentBind {
  226. if err := h.createWeChatBindPendingSession(c, cfg, providerSubject, openid, redirectTo, browserSessionKey, upstreamClaims); err != nil {
  227. switch infraerrors.Code(err) {
  228. case http.StatusConflict:
  229. redirectOAuthError(c, frontendCallback, "ownership_conflict", infraerrors.Reason(err), infraerrors.Message(err))
  230. case http.StatusUnauthorized, http.StatusForbidden:
  231. redirectOAuthError(c, frontendCallback, "auth_required", infraerrors.Reason(err), infraerrors.Message(err))
  232. default:
  233. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  234. }
  235. return
  236. }
  237. redirectToFrontendCallback(c, frontendCallback)
  238. return
  239. }
  240. existingIdentityUser, err := h.findOAuthIdentityUser(c.Request.Context(), identityRef)
  241. if err != nil {
  242. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  243. return
  244. }
  245. if existingIdentityUser == nil {
  246. existingIdentityUser, err = h.findWeChatUserByLegacyOpenID(c.Request.Context(), identityRef, cfg, openid)
  247. if err != nil {
  248. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  249. return
  250. }
  251. }
  252. if existingIdentityUser != nil {
  253. if err := h.ensureWeChatRuntimeIdentityBinding(c.Request.Context(), existingIdentityUser.ID, identityRef, upstreamClaims); err != nil {
  254. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  255. return
  256. }
  257. if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, existingIdentityUser.Email, redirectTo, browserSessionKey, upstreamClaims, nil, nil, &existingIdentityUser.ID); err != nil {
  258. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  259. return
  260. }
  261. redirectToFrontendCallback(c, frontendCallback)
  262. return
  263. }
  264. if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
  265. if err := h.createWeChatChoicePendingSession(
  266. c,
  267. identityRef,
  268. email,
  269. email,
  270. redirectTo,
  271. browserSessionKey,
  272. upstreamClaims,
  273. "",
  274. nil,
  275. true,
  276. ); err != nil {
  277. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  278. return
  279. }
  280. redirectToFrontendCallback(c, frontendCallback)
  281. return
  282. }
  283. if err := h.createWeChatChoicePendingSession(
  284. c,
  285. identityRef,
  286. email,
  287. email,
  288. redirectTo,
  289. browserSessionKey,
  290. upstreamClaims,
  291. "",
  292. nil,
  293. false,
  294. ); err != nil {
  295. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  296. return
  297. }
  298. redirectToFrontendCallback(c, frontendCallback)
  299. }
  300. // WeChatPaymentOAuthStart starts the WeChat payment OAuth flow.
  301. // GET /api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=/purchase
  302. func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
  303. cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
  304. if err != nil {
  305. response.ErrorFrom(c, err)
  306. return
  307. }
  308. paymentType := normalizeWeChatPaymentType(c.Query("payment_type"))
  309. if paymentType == "" {
  310. response.BadRequest(c, "Invalid payment type")
  311. return
  312. }
  313. state, err := oauth.GenerateState()
  314. if err != nil {
  315. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
  316. return
  317. }
  318. redirectTo := normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(c.Query("redirect")))
  319. if redirectTo == "" {
  320. redirectTo = wechatPaymentOAuthDefaultTo
  321. }
  322. rawContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
  323. PaymentType: paymentType,
  324. Amount: strings.TrimSpace(c.Query("amount")),
  325. OrderType: strings.TrimSpace(c.Query("order_type")),
  326. PlanID: parseWeChatPaymentPlanID(c.Query("plan_id")),
  327. })
  328. if err != nil {
  329. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONTEXT_ENCODE_FAILED", "failed to encode oauth context").WithCause(err))
  330. return
  331. }
  332. scope := normalizeWeChatPaymentScope(c.Query("scope"))
  333. secureCookie := isRequestHTTPS(c)
  334. wechatPaymentSetCookie(c, wechatPaymentOAuthStateName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
  335. wechatPaymentSetCookie(c, wechatPaymentOAuthRedirect, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
  336. wechatPaymentSetCookie(c, wechatPaymentOAuthContextName, encodeCookieValue(rawContext), wechatOAuthCookieMaxAgeSec, secureCookie)
  337. wechatPaymentSetCookie(c, wechatPaymentOAuthScope, encodeCookieValue(scope), wechatOAuthCookieMaxAgeSec, secureCookie)
  338. cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
  339. cfg.scope = scope
  340. authURL, err := buildWeChatAuthorizeURL(cfg, state)
  341. if err != nil {
  342. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
  343. return
  344. }
  345. c.Redirect(http.StatusFound, authURL)
  346. }
  347. // WeChatPaymentOAuthCallback exchanges a payment OAuth code for an OpenID and
  348. // forwards the browser back to the frontend callback route.
  349. func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
  350. frontendCallback := wechatPaymentOAuthFrontendCB
  351. if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
  352. redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
  353. return
  354. }
  355. code := strings.TrimSpace(c.Query("code"))
  356. state := strings.TrimSpace(c.Query("state"))
  357. if code == "" || state == "" {
  358. redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
  359. return
  360. }
  361. secureCookie := isRequestHTTPS(c)
  362. defer func() {
  363. wechatPaymentClearCookie(c, wechatPaymentOAuthStateName, secureCookie)
  364. wechatPaymentClearCookie(c, wechatPaymentOAuthRedirect, secureCookie)
  365. wechatPaymentClearCookie(c, wechatPaymentOAuthContextName, secureCookie)
  366. wechatPaymentClearCookie(c, wechatPaymentOAuthScope, secureCookie)
  367. }()
  368. expectedState, err := readCookieDecoded(c, wechatPaymentOAuthStateName)
  369. if err != nil || expectedState == "" || state != expectedState {
  370. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
  371. return
  372. }
  373. redirectTo, _ := readCookieDecoded(c, wechatPaymentOAuthRedirect)
  374. redirectTo = normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(redirectTo))
  375. if redirectTo == "" {
  376. redirectTo = wechatPaymentOAuthDefaultTo
  377. }
  378. rawContext, _ := readCookieDecoded(c, wechatPaymentOAuthContextName)
  379. paymentContext, err := decodeWeChatPaymentOAuthContext(rawContext)
  380. if err != nil {
  381. redirectOAuthError(c, frontendCallback, "invalid_context", "invalid oauth context", "")
  382. return
  383. }
  384. if paymentContext.PaymentType == "" {
  385. paymentContext.PaymentType = payment.TypeWxpay
  386. }
  387. scope, _ := readCookieDecoded(c, wechatPaymentOAuthScope)
  388. scope = normalizeWeChatPaymentScope(scope)
  389. cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
  390. if err != nil {
  391. redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
  392. return
  393. }
  394. cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
  395. tokenResp, err := exchangeWeChatOAuthCode(c.Request.Context(), cfg, code)
  396. if err != nil {
  397. redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", err.Error())
  398. return
  399. }
  400. openid := strings.TrimSpace(tokenResp.OpenID)
  401. if openid == "" {
  402. redirectOAuthError(c, frontendCallback, "missing_openid", "missing openid", "")
  403. return
  404. }
  405. if strings.TrimSpace(tokenResp.Scope) != "" {
  406. scope = strings.TrimSpace(tokenResp.Scope)
  407. }
  408. resumeToken, err := h.wechatPaymentResumeService().CreateWeChatPaymentResumeToken(service.WeChatPaymentResumeClaims{
  409. OpenID: openid,
  410. PaymentType: paymentContext.PaymentType,
  411. Amount: paymentContext.Amount,
  412. OrderType: paymentContext.OrderType,
  413. PlanID: paymentContext.PlanID,
  414. RedirectTo: redirectTo,
  415. Scope: scope,
  416. })
  417. if err != nil {
  418. redirectOAuthError(c, frontendCallback, "invalid_context", "failed to encode payment resume context", "")
  419. return
  420. }
  421. fragment := url.Values{}
  422. fragment.Set("wechat_resume_token", resumeToken)
  423. fragment.Set("redirect", redirectTo)
  424. redirectWithFragment(c, frontendCallback, fragment)
  425. }
  426. func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService {
  427. var legacyKey []byte
  428. key, err := payment.ProvideEncryptionKey(h.cfg)
  429. if err == nil {
  430. legacyKey = []byte(key)
  431. }
  432. return service.NewLegacyAwarePaymentResumeService(legacyKey)
  433. }
  434. type completeWeChatOAuthRequest struct {
  435. InvitationCode string `json:"invitation_code" binding:"required"`
  436. AffCode string `json:"aff_code,omitempty"`
  437. AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
  438. AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
  439. }
  440. // CompleteWeChatOAuthRegistration completes a pending WeChat OAuth registration by
  441. // validating the invitation code and consuming the current pending browser session.
  442. // POST /api/v1/auth/oauth/wechat/complete-registration
  443. func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
  444. var req completeWeChatOAuthRequest
  445. if err := c.ShouldBindJSON(&req); err != nil {
  446. c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
  447. return
  448. }
  449. secureCookie := isRequestHTTPS(c)
  450. sessionToken, err := readOAuthPendingSessionCookie(c)
  451. if err != nil {
  452. clearOAuthPendingSessionCookie(c, secureCookie)
  453. clearOAuthPendingBrowserCookie(c, secureCookie)
  454. response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
  455. return
  456. }
  457. browserSessionKey, err := readOAuthPendingBrowserCookie(c)
  458. if err != nil {
  459. clearOAuthPendingSessionCookie(c, secureCookie)
  460. clearOAuthPendingBrowserCookie(c, secureCookie)
  461. response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
  462. return
  463. }
  464. pendingSvc, err := h.pendingIdentityService()
  465. if err != nil {
  466. response.ErrorFrom(c, err)
  467. return
  468. }
  469. session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
  470. if err != nil {
  471. clearOAuthPendingSessionCookie(c, secureCookie)
  472. clearOAuthPendingBrowserCookie(c, secureCookie)
  473. response.ErrorFrom(c, err)
  474. return
  475. }
  476. if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
  477. response.ErrorFrom(c, err)
  478. return
  479. }
  480. if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
  481. response.ErrorFrom(c, err)
  482. return
  483. } else if handled {
  484. c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
  485. return
  486. } else {
  487. session = updatedSession
  488. }
  489. if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
  490. response.ErrorFrom(c, err)
  491. return
  492. }
  493. email := strings.TrimSpace(session.ResolvedEmail)
  494. username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
  495. if email == "" || username == "" {
  496. response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
  497. return
  498. }
  499. tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
  500. c.Request.Context(),
  501. email,
  502. username,
  503. req.InvitationCode,
  504. req.AffCode,
  505. pendingOAuthPromoCode(session),
  506. "wechat",
  507. )
  508. if err != nil {
  509. response.ErrorFrom(c, err)
  510. return
  511. }
  512. decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
  513. AdoptDisplayName: req.AdoptDisplayName,
  514. AdoptAvatar: req.AdoptAvatar,
  515. })
  516. if err != nil {
  517. response.ErrorFrom(c, err)
  518. return
  519. }
  520. if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), h.authService, h.userService, session, decision, &user.ID); err != nil {
  521. response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
  522. return
  523. }
  524. h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
  525. if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
  526. clearOAuthPendingSessionCookie(c, secureCookie)
  527. clearOAuthPendingBrowserCookie(c, secureCookie)
  528. response.ErrorFrom(c, err)
  529. return
  530. }
  531. clearOAuthPendingSessionCookie(c, secureCookie)
  532. clearOAuthPendingBrowserCookie(c, secureCookie)
  533. c.JSON(http.StatusOK, gin.H{
  534. "access_token": tokenPair.AccessToken,
  535. "refresh_token": tokenPair.RefreshToken,
  536. "expires_in": tokenPair.ExpiresIn,
  537. "token_type": "Bearer",
  538. })
  539. }
  540. func (h *AuthHandler) createWeChatPendingSession(
  541. c *gin.Context,
  542. intent string,
  543. providerSubject string,
  544. email string,
  545. redirectTo string,
  546. browserSessionKey string,
  547. upstreamClaims map[string]any,
  548. tokenPair *service.TokenPair,
  549. authErr error,
  550. targetUserID *int64,
  551. ) error {
  552. completionResponse := map[string]any{
  553. "redirect": redirectTo,
  554. }
  555. if authErr != nil {
  556. if errors.Is(authErr, service.ErrOAuthInvitationRequired) {
  557. completionResponse["error"] = "invitation_required"
  558. } else {
  559. return authErr
  560. }
  561. } else if tokenPair != nil {
  562. completionResponse["access_token"] = tokenPair.AccessToken
  563. completionResponse["refresh_token"] = tokenPair.RefreshToken
  564. completionResponse["expires_in"] = tokenPair.ExpiresIn
  565. completionResponse["token_type"] = "Bearer"
  566. }
  567. return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  568. Intent: intent,
  569. Identity: service.PendingAuthIdentityKey{
  570. ProviderType: "wechat",
  571. ProviderKey: wechatOAuthProviderKey,
  572. ProviderSubject: providerSubject,
  573. },
  574. TargetUserID: targetUserID,
  575. ResolvedEmail: email,
  576. RedirectTo: redirectTo,
  577. BrowserSessionKey: browserSessionKey,
  578. UpstreamIdentityClaims: upstreamClaims,
  579. CompletionResponse: completionResponse,
  580. })
  581. }
  582. func (h *AuthHandler) createWeChatChoicePendingSession(
  583. c *gin.Context,
  584. identity service.PendingAuthIdentityKey,
  585. suggestedEmail string,
  586. resolvedEmail string,
  587. redirectTo string,
  588. browserSessionKey string,
  589. upstreamClaims map[string]any,
  590. compatEmail string,
  591. compatEmailUser *dbent.User,
  592. forceEmailOnSignup bool,
  593. ) error {
  594. suggestionEmail := strings.TrimSpace(suggestedEmail)
  595. canonicalEmail := strings.TrimSpace(resolvedEmail)
  596. if suggestionEmail == "" {
  597. suggestionEmail = canonicalEmail
  598. }
  599. completionResponse := map[string]any{
  600. "step": oauthPendingChoiceStep,
  601. "adoption_required": true,
  602. "redirect": strings.TrimSpace(redirectTo),
  603. "email": suggestionEmail,
  604. "resolved_email": canonicalEmail,
  605. "existing_account_email": "",
  606. "existing_account_bindable": false,
  607. "create_account_allowed": true,
  608. "force_email_on_signup": forceEmailOnSignup,
  609. "choice_reason": "third_party_signup",
  610. }
  611. if strings.TrimSpace(compatEmail) != "" {
  612. completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
  613. }
  614. if compatEmailUser != nil {
  615. completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
  616. completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
  617. completionResponse["existing_account_bindable"] = true
  618. completionResponse["choice_reason"] = "compat_email_match"
  619. }
  620. if forceEmailOnSignup {
  621. completionResponse["choice_reason"] = "force_email_on_signup"
  622. }
  623. resolvedChoiceEmail := suggestionEmail
  624. if compatEmailUser != nil {
  625. resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
  626. }
  627. return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  628. Intent: oauthIntentLogin,
  629. Identity: identity,
  630. ResolvedEmail: resolvedChoiceEmail,
  631. RedirectTo: redirectTo,
  632. BrowserSessionKey: browserSessionKey,
  633. UpstreamIdentityClaims: upstreamClaims,
  634. CompletionResponse: completionResponse,
  635. })
  636. }
  637. func (h *AuthHandler) createWeChatBindPendingSession(
  638. c *gin.Context,
  639. cfg wechatOAuthConfig,
  640. providerSubject string,
  641. channelSubject string,
  642. redirectTo string,
  643. browserSessionKey string,
  644. upstreamClaims map[string]any,
  645. ) error {
  646. currentUser, err := h.readOAuthBindTargetUser(c, wechatOAuthBindUserCookieName)
  647. if err != nil {
  648. return err
  649. }
  650. if err := h.ensureWeChatBindOwnership(c.Request.Context(), currentUser.ID, providerSubject, cfg, channelSubject); err != nil {
  651. return err
  652. }
  653. return h.createWeChatPendingSession(
  654. c,
  655. wechatOAuthIntentBind,
  656. providerSubject,
  657. currentUser.Email,
  658. redirectTo,
  659. browserSessionKey,
  660. upstreamClaims,
  661. nil,
  662. nil,
  663. &currentUser.ID,
  664. )
  665. }
  666. func (h *AuthHandler) readOAuthBindTargetUser(c *gin.Context, cookieName string) (*dbent.User, error) {
  667. client := h.entClient()
  668. if client == nil {
  669. return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  670. }
  671. userID, err := h.readOAuthBindUserIDFromCookie(c, cookieName)
  672. if err != nil {
  673. return nil, infraerrors.Unauthorized("AUTH_REQUIRED", "current user is required to bind wechat account")
  674. }
  675. userEntity, err := client.User.Get(c.Request.Context(), userID)
  676. if err != nil {
  677. if dbent.IsNotFound(err) {
  678. return nil, infraerrors.Unauthorized("AUTH_REQUIRED", "current user is required to bind wechat account")
  679. }
  680. return nil, infraerrors.InternalServer("WECHAT_BIND_USER_LOOKUP_FAILED", "failed to load current user").WithCause(err)
  681. }
  682. return userEntity, nil
  683. }
  684. func (h *AuthHandler) ensureWeChatBindOwnership(
  685. ctx context.Context,
  686. userID int64,
  687. providerSubject string,
  688. cfg wechatOAuthConfig,
  689. channelSubject string,
  690. ) error {
  691. client := h.entClient()
  692. if client == nil {
  693. return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  694. }
  695. identities, err := client.AuthIdentity.Query().
  696. Where(
  697. authidentity.ProviderTypeEQ("wechat"),
  698. authidentity.ProviderKeyIn(wechatCompatibleProviderKeys(wechatOAuthProviderKey)...),
  699. authidentity.ProviderSubjectEQ(strings.TrimSpace(providerSubject)),
  700. ).
  701. All(ctx)
  702. if err != nil {
  703. return infraerrors.InternalServer("WECHAT_BIND_LOOKUP_FAILED", "failed to inspect wechat identity ownership").WithCause(err)
  704. }
  705. for _, identity := range identities {
  706. if identity != nil && identity.UserID != userID {
  707. activeOwner, lookupErr := findActiveUserByID(ctx, client, identity.UserID)
  708. if lookupErr != nil {
  709. return lookupErr
  710. }
  711. if activeOwner != nil {
  712. return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
  713. }
  714. }
  715. }
  716. channelSubject = strings.TrimSpace(channelSubject)
  717. channelAppID := strings.TrimSpace(cfg.appID)
  718. if channelSubject == "" || channelAppID == "" {
  719. return nil
  720. }
  721. channels, err := client.AuthIdentityChannel.Query().
  722. Where(
  723. authidentitychannel.ProviderTypeEQ("wechat"),
  724. authidentitychannel.ProviderKeyIn(wechatCompatibleProviderKeys(wechatOAuthProviderKey)...),
  725. authidentitychannel.ChannelEQ(strings.TrimSpace(cfg.mode)),
  726. authidentitychannel.ChannelAppIDEQ(channelAppID),
  727. authidentitychannel.ChannelSubjectEQ(channelSubject),
  728. ).
  729. WithIdentity().
  730. All(ctx)
  731. if err != nil {
  732. return infraerrors.InternalServer("WECHAT_BIND_CHANNEL_LOOKUP_FAILED", "failed to inspect wechat identity channel ownership").WithCause(err)
  733. }
  734. for _, channel := range channels {
  735. if channel != nil && channel.Edges.Identity != nil && channel.Edges.Identity.UserID != userID {
  736. activeOwner, lookupErr := findActiveUserByID(ctx, client, channel.Edges.Identity.UserID)
  737. if lookupErr != nil {
  738. return lookupErr
  739. }
  740. if activeOwner != nil {
  741. return infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user")
  742. }
  743. }
  744. }
  745. return nil
  746. }
  747. func (h *AuthHandler) findWeChatUserByLegacyOpenID(
  748. ctx context.Context,
  749. identity service.PendingAuthIdentityKey,
  750. cfg wechatOAuthConfig,
  751. openid string,
  752. ) (*dbent.User, error) {
  753. client := h.entClient()
  754. if client == nil {
  755. return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  756. }
  757. providerType := strings.TrimSpace(identity.ProviderType)
  758. providerSubject := strings.TrimSpace(identity.ProviderSubject)
  759. providerKeys := wechatCompatibleProviderKeys(identity.ProviderKey)
  760. if providerSubject != "" {
  761. records, err := client.AuthIdentity.Query().
  762. Where(
  763. authidentity.ProviderTypeEQ(providerType),
  764. authidentity.ProviderKeyIn(providerKeys...),
  765. authidentity.ProviderSubjectEQ(providerSubject),
  766. ).
  767. WithUser().
  768. All(ctx)
  769. if err != nil {
  770. return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
  771. }
  772. if user, err := singleWeChatIdentityUser(records); err != nil || user != nil {
  773. if err != nil || user == nil {
  774. return user, err
  775. }
  776. return findActiveUserByID(ctx, client, user.ID)
  777. }
  778. }
  779. openid = strings.TrimSpace(openid)
  780. channel := strings.TrimSpace(cfg.mode)
  781. channelAppID := strings.TrimSpace(cfg.appID)
  782. if openid != "" && channel != "" && channelAppID != "" {
  783. records, err := client.AuthIdentityChannel.Query().
  784. Where(
  785. authidentitychannel.ProviderTypeEQ(providerType),
  786. authidentitychannel.ProviderKeyIn(providerKeys...),
  787. authidentitychannel.ChannelEQ(channel),
  788. authidentitychannel.ChannelAppIDEQ(channelAppID),
  789. authidentitychannel.ChannelSubjectEQ(openid),
  790. ).
  791. WithIdentity(func(q *dbent.AuthIdentityQuery) {
  792. q.WithUser()
  793. }).
  794. All(ctx)
  795. if err != nil {
  796. return nil, infraerrors.InternalServer("AUTH_IDENTITY_CHANNEL_LOOKUP_FAILED", "failed to inspect auth identity channel ownership").WithCause(err)
  797. }
  798. if user, err := singleWeChatChannelUser(records); err != nil || user != nil {
  799. if err != nil || user == nil {
  800. return user, err
  801. }
  802. return findActiveUserByID(ctx, client, user.ID)
  803. }
  804. }
  805. if openid == "" {
  806. return nil, nil
  807. }
  808. records, err := client.AuthIdentity.Query().
  809. Where(
  810. authidentity.ProviderTypeEQ(providerType),
  811. authidentity.ProviderKeyIn(providerKeys...),
  812. authidentity.ProviderSubjectEQ(openid),
  813. ).
  814. WithUser().
  815. All(ctx)
  816. if err != nil {
  817. return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
  818. }
  819. user, err := singleWeChatIdentityUser(records)
  820. if err != nil || user == nil {
  821. return user, err
  822. }
  823. return findActiveUserByID(ctx, client, user.ID)
  824. }
  825. func wechatCompatibleProviderKeys(providerKey string) []string {
  826. preferred := strings.TrimSpace(providerKey)
  827. if preferred == "" {
  828. preferred = wechatOAuthProviderKey
  829. }
  830. keys := []string{preferred}
  831. if !strings.EqualFold(preferred, wechatOAuthLegacyProviderKey) {
  832. keys = append(keys, wechatOAuthLegacyProviderKey)
  833. }
  834. return keys
  835. }
  836. func singleWeChatIdentityUser(records []*dbent.AuthIdentity) (*dbent.User, error) {
  837. var resolved *dbent.User
  838. for _, record := range records {
  839. if record == nil || record.Edges.User == nil {
  840. continue
  841. }
  842. if resolved == nil {
  843. resolved = record.Edges.User
  844. continue
  845. }
  846. if resolved.ID != record.Edges.User.ID {
  847. return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
  848. }
  849. }
  850. return resolved, nil
  851. }
  852. func singleWeChatChannelUser(records []*dbent.AuthIdentityChannel) (*dbent.User, error) {
  853. var resolved *dbent.User
  854. for _, record := range records {
  855. if record == nil || record.Edges.Identity == nil || record.Edges.Identity.Edges.User == nil {
  856. continue
  857. }
  858. if resolved == nil {
  859. resolved = record.Edges.Identity.Edges.User
  860. continue
  861. }
  862. if resolved.ID != record.Edges.Identity.Edges.User.ID {
  863. return nil, infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user")
  864. }
  865. }
  866. return resolved, nil
  867. }
  868. func (h *AuthHandler) ensureWeChatRuntimeIdentityBinding(
  869. ctx context.Context,
  870. userID int64,
  871. identity service.PendingAuthIdentityKey,
  872. upstreamClaims map[string]any,
  873. ) error {
  874. client := h.entClient()
  875. if client == nil {
  876. return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  877. }
  878. tx, err := client.Tx(ctx)
  879. if err != nil {
  880. return infraerrors.InternalServer("AUTH_IDENTITY_BIND_FAILED", "failed to begin wechat identity repair transaction").WithCause(err)
  881. }
  882. defer func() { _ = tx.Rollback() }()
  883. _, err = ensurePendingOAuthIdentityForUser(dbent.NewTxContext(ctx, tx), tx, &dbent.PendingAuthSession{
  884. ProviderType: strings.TrimSpace(identity.ProviderType),
  885. ProviderKey: strings.TrimSpace(identity.ProviderKey),
  886. ProviderSubject: strings.TrimSpace(identity.ProviderSubject),
  887. UpstreamIdentityClaims: cloneOAuthMetadata(upstreamClaims),
  888. }, userID)
  889. if err != nil {
  890. return err
  891. }
  892. return tx.Commit()
  893. }
  894. func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, c *gin.Context) (wechatOAuthConfig, error) {
  895. mode, err := resolveWeChatOAuthMode(rawMode, c)
  896. if err != nil {
  897. return wechatOAuthConfig{}, err
  898. }
  899. if h == nil || h.settingSvc == nil {
  900. return wechatOAuthConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "wechat oauth settings service not ready")
  901. }
  902. apiBaseURL := ""
  903. if h != nil && h.settingSvc != nil {
  904. settings, err := h.settingSvc.GetAllSettings(ctx)
  905. if err == nil && settings != nil {
  906. apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
  907. }
  908. }
  909. effective, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)
  910. if err != nil {
  911. return wechatOAuthConfig{}, err
  912. }
  913. if !effective.SupportsMode(mode) {
  914. return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
  915. }
  916. cfg := wechatOAuthConfig{
  917. mode: mode,
  918. appID: strings.TrimSpace(effective.AppIDForMode(mode)),
  919. appSecret: strings.TrimSpace(effective.AppSecretForMode(mode)),
  920. redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")),
  921. frontendCallback: firstNonEmpty(strings.TrimSpace(effective.FrontendRedirectURL), wechatOAuthDefaultFrontendCB),
  922. scope: effective.ScopeForMode(mode),
  923. openEnabled: effective.OpenEnabled,
  924. mpEnabled: effective.MPEnabled,
  925. }
  926. switch mode {
  927. case "mp":
  928. cfg.authorizeURL = "https://open.weixin.qq.com/connect/oauth2/authorize"
  929. default:
  930. cfg.authorizeURL = "https://open.weixin.qq.com/connect/qrconnect"
  931. }
  932. if strings.TrimSpace(cfg.redirectURI) == "" {
  933. return wechatOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured")
  934. }
  935. return cfg, nil
  936. }
  937. func (cfg wechatOAuthConfig) requiresUnionID() bool {
  938. return cfg.openEnabled && cfg.mpEnabled
  939. }
  940. func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string {
  941. if h != nil && h.settingSvc != nil {
  942. cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)
  943. if err == nil && strings.TrimSpace(cfg.FrontendRedirectURL) != "" {
  944. return strings.TrimSpace(cfg.FrontendRedirectURL)
  945. }
  946. }
  947. return wechatOAuthDefaultFrontendCB
  948. }
  949. func resolveWeChatOAuthMode(rawMode string, c *gin.Context) (string, error) {
  950. mode := strings.ToLower(strings.TrimSpace(rawMode))
  951. if mode == "" {
  952. if isWeChatBrowserRequest(c) {
  953. return "mp", nil
  954. }
  955. return "open", nil
  956. }
  957. if mode != "open" && mode != "mp" {
  958. return "", infraerrors.BadRequest("INVALID_MODE", "wechat oauth mode must be open or mp")
  959. }
  960. return mode, nil
  961. }
  962. func isWeChatBrowserRequest(c *gin.Context) bool {
  963. if c == nil || c.Request == nil {
  964. return false
  965. }
  966. return strings.Contains(strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent"))), "micromessenger")
  967. }
  968. func normalizeWeChatOAuthIntent(raw string) string {
  969. switch strings.ToLower(strings.TrimSpace(raw)) {
  970. case "", "login":
  971. return wechatOAuthIntentLogin
  972. case "bind", "bind_current_user":
  973. return wechatOAuthIntentBind
  974. case "adopt", "adopt_existing_user_by_email":
  975. return wechatOAuthIntentAdoptEmail
  976. default:
  977. return wechatOAuthIntentLogin
  978. }
  979. }
  980. func buildWeChatAuthorizeURL(cfg wechatOAuthConfig, state string) (string, error) {
  981. u, err := url.Parse(cfg.authorizeURL)
  982. if err != nil {
  983. return "", fmt.Errorf("parse authorize url: %w", err)
  984. }
  985. query := u.Query()
  986. query.Set("appid", cfg.appID)
  987. query.Set("redirect_uri", cfg.redirectURI)
  988. query.Set("response_type", "code")
  989. query.Set("scope", cfg.scope)
  990. query.Set("state", state)
  991. u.RawQuery = query.Encode()
  992. u.Fragment = "wechat_redirect"
  993. return u.String(), nil
  994. }
  995. func resolveWeChatOAuthAbsoluteURL(apiBaseURL string, c *gin.Context, callbackPath string) string {
  996. callbackPath = strings.TrimSpace(callbackPath)
  997. if callbackPath == "" {
  998. return ""
  999. }
  1000. if raw := strings.TrimSpace(apiBaseURL); raw != "" {
  1001. if parsed, err := url.Parse(raw); err == nil && parsed.Scheme != "" && parsed.Host != "" {
  1002. basePath := strings.TrimRight(parsed.EscapedPath(), "/")
  1003. targetPath := callbackPath
  1004. if basePath != "" && strings.HasSuffix(basePath, "/api/v1") && strings.HasPrefix(callbackPath, "/api/v1") {
  1005. targetPath = basePath + strings.TrimPrefix(callbackPath, "/api/v1")
  1006. } else if basePath != "" {
  1007. targetPath = basePath + callbackPath
  1008. }
  1009. return parsed.Scheme + "://" + parsed.Host + targetPath
  1010. }
  1011. }
  1012. if c == nil || c.Request == nil {
  1013. return ""
  1014. }
  1015. scheme := "http"
  1016. if isRequestHTTPS(c) {
  1017. scheme = "https"
  1018. }
  1019. host := strings.TrimSpace(c.Request.Host)
  1020. if forwardedHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
  1021. host = forwardedHost
  1022. }
  1023. if host == "" {
  1024. return ""
  1025. }
  1026. return scheme + "://" + host + callbackPath
  1027. }
  1028. func fetchWeChatOAuthIdentity(ctx context.Context, cfg wechatOAuthConfig, code string) (*wechatOAuthTokenResponse, *wechatOAuthUserInfoResponse, error) {
  1029. tokenResp, err := exchangeWeChatOAuthCode(ctx, cfg, code)
  1030. if err != nil {
  1031. return nil, nil, err
  1032. }
  1033. userInfo, err := fetchWeChatUserInfo(ctx, tokenResp)
  1034. if err != nil {
  1035. return nil, nil, err
  1036. }
  1037. return tokenResp, userInfo, nil
  1038. }
  1039. func exchangeWeChatOAuthCode(ctx context.Context, cfg wechatOAuthConfig, code string) (*wechatOAuthTokenResponse, error) {
  1040. endpoint, err := url.Parse(wechatOAuthAccessTokenURL)
  1041. if err != nil {
  1042. return nil, fmt.Errorf("parse wechat access token url: %w", err)
  1043. }
  1044. query := endpoint.Query()
  1045. query.Set("appid", cfg.appID)
  1046. query.Set("secret", cfg.appSecret)
  1047. query.Set("code", strings.TrimSpace(code))
  1048. query.Set("grant_type", "authorization_code")
  1049. endpoint.RawQuery = query.Encode()
  1050. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
  1051. if err != nil {
  1052. return nil, fmt.Errorf("build wechat access token request: %w", err)
  1053. }
  1054. client := &http.Client{Timeout: 30 * time.Second}
  1055. resp, err := client.Do(req)
  1056. if err != nil {
  1057. return nil, fmt.Errorf("request wechat access token: %w", err)
  1058. }
  1059. defer func() { _ = resp.Body.Close() }()
  1060. body, err := io.ReadAll(resp.Body)
  1061. if err != nil {
  1062. return nil, fmt.Errorf("read wechat access token response: %w", err)
  1063. }
  1064. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  1065. return nil, fmt.Errorf("wechat access token status=%d", resp.StatusCode)
  1066. }
  1067. var tokenResp wechatOAuthTokenResponse
  1068. if err := json.Unmarshal(body, &tokenResp); err != nil {
  1069. return nil, fmt.Errorf("decode wechat access token response: %w", err)
  1070. }
  1071. if tokenResp.ErrCode != 0 {
  1072. return nil, fmt.Errorf("wechat access token error=%d %s", tokenResp.ErrCode, strings.TrimSpace(tokenResp.ErrMsg))
  1073. }
  1074. if strings.TrimSpace(tokenResp.AccessToken) == "" {
  1075. return nil, fmt.Errorf("wechat access token missing access_token")
  1076. }
  1077. return &tokenResp, nil
  1078. }
  1079. func fetchWeChatUserInfo(ctx context.Context, tokenResp *wechatOAuthTokenResponse) (*wechatOAuthUserInfoResponse, error) {
  1080. if tokenResp == nil {
  1081. return nil, fmt.Errorf("wechat token response is nil")
  1082. }
  1083. endpoint, err := url.Parse(wechatOAuthUserInfoURL)
  1084. if err != nil {
  1085. return nil, fmt.Errorf("parse wechat userinfo url: %w", err)
  1086. }
  1087. query := endpoint.Query()
  1088. query.Set("access_token", strings.TrimSpace(tokenResp.AccessToken))
  1089. query.Set("openid", strings.TrimSpace(tokenResp.OpenID))
  1090. query.Set("lang", "zh_CN")
  1091. endpoint.RawQuery = query.Encode()
  1092. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
  1093. if err != nil {
  1094. return nil, fmt.Errorf("build wechat userinfo request: %w", err)
  1095. }
  1096. client := &http.Client{Timeout: 30 * time.Second}
  1097. resp, err := client.Do(req)
  1098. if err != nil {
  1099. return nil, fmt.Errorf("request wechat userinfo: %w", err)
  1100. }
  1101. defer func() { _ = resp.Body.Close() }()
  1102. body, err := io.ReadAll(resp.Body)
  1103. if err != nil {
  1104. return nil, fmt.Errorf("read wechat userinfo response: %w", err)
  1105. }
  1106. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  1107. return nil, fmt.Errorf("wechat userinfo status=%d", resp.StatusCode)
  1108. }
  1109. var userInfo wechatOAuthUserInfoResponse
  1110. if err := json.Unmarshal(body, &userInfo); err != nil {
  1111. return nil, fmt.Errorf("decode wechat userinfo response: %w", err)
  1112. }
  1113. if userInfo.ErrCode != 0 {
  1114. return nil, fmt.Errorf("wechat userinfo error=%d %s", userInfo.ErrCode, strings.TrimSpace(userInfo.ErrMsg))
  1115. }
  1116. return &userInfo, nil
  1117. }
  1118. func wechatSyntheticEmail(subject string) string {
  1119. subject = strings.TrimSpace(subject)
  1120. if subject == "" {
  1121. return ""
  1122. }
  1123. return "wechat-" + subject + service.WeChatConnectSyntheticEmailDomain
  1124. }
  1125. func wechatFallbackUsername(subject string) string {
  1126. subject = strings.TrimSpace(subject)
  1127. if subject == "" {
  1128. return "wechat_user"
  1129. }
  1130. return "wechat_" + truncateFragmentValue(subject)
  1131. }
  1132. func wechatSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
  1133. http.SetCookie(c.Writer, &http.Cookie{
  1134. Name: name,
  1135. Value: value,
  1136. Path: wechatOAuthCookiePath,
  1137. MaxAge: maxAgeSec,
  1138. HttpOnly: true,
  1139. Secure: secure,
  1140. SameSite: http.SameSiteLaxMode,
  1141. })
  1142. }
  1143. func wechatClearCookie(c *gin.Context, name string, secure bool) {
  1144. http.SetCookie(c.Writer, &http.Cookie{
  1145. Name: name,
  1146. Value: "",
  1147. Path: wechatOAuthCookiePath,
  1148. MaxAge: -1,
  1149. HttpOnly: true,
  1150. Secure: secure,
  1151. SameSite: http.SameSiteLaxMode,
  1152. })
  1153. }
  1154. func normalizeWeChatPaymentType(raw string) string {
  1155. switch strings.TrimSpace(raw) {
  1156. case payment.TypeWxpay, payment.TypeWxpayDirect:
  1157. return strings.TrimSpace(raw)
  1158. default:
  1159. return ""
  1160. }
  1161. }
  1162. func normalizeWeChatPaymentScope(raw string) string {
  1163. for _, part := range strings.FieldsFunc(strings.TrimSpace(raw), func(r rune) bool {
  1164. return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
  1165. }) {
  1166. switch strings.TrimSpace(part) {
  1167. case "snsapi_userinfo":
  1168. return "snsapi_userinfo"
  1169. case "snsapi_base":
  1170. return "snsapi_base"
  1171. }
  1172. }
  1173. return "snsapi_base"
  1174. }
  1175. func normalizeWeChatPaymentRedirectPath(path string) string {
  1176. path = strings.TrimSpace(path)
  1177. if path == "" {
  1178. return wechatPaymentOAuthDefaultTo
  1179. }
  1180. if path == "/payment" {
  1181. return "/purchase"
  1182. }
  1183. if strings.HasPrefix(path, "/payment?") {
  1184. return "/purchase" + strings.TrimPrefix(path, "/payment")
  1185. }
  1186. return path
  1187. }
  1188. func (h *AuthHandler) resolveWeChatPaymentOAuthCallbackURL(ctx context.Context, c *gin.Context) string {
  1189. apiBaseURL := ""
  1190. if h != nil && h.settingSvc != nil {
  1191. if settings, err := h.settingSvc.GetAllSettings(ctx); err == nil && settings != nil {
  1192. apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
  1193. }
  1194. }
  1195. return resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/payment/callback")
  1196. }
  1197. func encodeWeChatPaymentOAuthContext(ctx wechatPaymentOAuthContext) (string, error) {
  1198. data, err := json.Marshal(ctx)
  1199. if err != nil {
  1200. return "", err
  1201. }
  1202. return string(data), nil
  1203. }
  1204. func decodeWeChatPaymentOAuthContext(raw string) (wechatPaymentOAuthContext, error) {
  1205. raw = strings.TrimSpace(raw)
  1206. if raw == "" {
  1207. return wechatPaymentOAuthContext{}, nil
  1208. }
  1209. var ctx wechatPaymentOAuthContext
  1210. if err := json.Unmarshal([]byte(raw), &ctx); err != nil {
  1211. return wechatPaymentOAuthContext{}, err
  1212. }
  1213. return ctx, nil
  1214. }
  1215. func parseWeChatPaymentPlanID(raw string) int64 {
  1216. id, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
  1217. return id
  1218. }
  1219. func wechatPaymentSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
  1220. http.SetCookie(c.Writer, &http.Cookie{
  1221. Name: name,
  1222. Value: value,
  1223. Path: wechatPaymentOAuthCookiePath,
  1224. MaxAge: maxAgeSec,
  1225. HttpOnly: true,
  1226. Secure: secure,
  1227. SameSite: http.SameSiteLaxMode,
  1228. })
  1229. }
  1230. func wechatPaymentClearCookie(c *gin.Context, name string, secure bool) {
  1231. http.SetCookie(c.Writer, &http.Cookie{
  1232. Name: name,
  1233. Value: "",
  1234. Path: wechatPaymentOAuthCookiePath,
  1235. MaxAge: -1,
  1236. HttpOnly: true,
  1237. Secure: secure,
  1238. SameSite: http.SameSiteLaxMode,
  1239. })
  1240. }