From c7a1b7f13d8e4cfb6e07d9171b9237853ada7fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:34:39 +0200 Subject: [PATCH] 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 Devin --- ROADMAP.md | 1 + .../public/locales/en-US/application.json | 5 +- frontend/public/locales/en-US/dashboard.json | 8 + .../public/locales/zh-CN/application.json | 5 +- frontend/public/locales/zh-CN/dashboard.json | 8 + frontend/src/api/site.ts | 1 + .../src/component/Admin/Settings/Settings.tsx | 4 + .../Settings/UserSession/UserSession.tsx | 4 + .../UserSession/WeChatConnectSettings.tsx | 104 ++++++ .../Pages/Login/Phases/PhaseCollectEmail.tsx | 2 + .../Pages/Login/Signin/WeChatLoginButton.tsx | 31 ++ .../Setting/Security/SecuritySetting.tsx | 25 +- inventory/setting.go | 5 + inventory/ssobinding.go | 3 +- pkg/setting/provider.go | 10 + pkg/setting/types.go | 10 + routers/controllers/user.go | 13 + routers/router.go | 13 + service/basic/site.go | 3 + service/user/qq.go | 2 +- service/user/wechat.go | 347 ++++++++++++++++++ service/user/wechat_test.go | 23 ++ 22 files changed, 619 insertions(+), 8 deletions(-) create mode 100644 frontend/src/component/Admin/Settings/UserSession/WeChatConnectSettings.tsx create mode 100644 frontend/src/component/Pages/Login/Signin/WeChatLoginButton.tsx create mode 100644 service/user/wechat.go create mode 100644 service/user/wechat_test.go diff --git a/ROADMAP.md b/ROADMAP.md index 7dad41a8..592efe2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -226,6 +226,7 @@ Order = user-visible value first; each ships with backend + UI + tests. - [x] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `_i18n` JSON map by language tag (exact → bare primary subtag → wildcard `*` → base value); `SiteBasicLocalized` covers site name/title/description; consumed by site config, announcement endpoint, share-preview OG tags, index.html placeholders, WOPI breadcrumb, and email templates (recipient language); SKU gains `name_i18n`/`des_i18n` columns resolved per buyer language in the shop; admin gets a reusable `LocalizedFields` accordion (per-language inputs) wired into site name/description/announcement and SKU name/description; en+zh locales - [x] Weighted policy selection (upstream #2178 item 2) — `GroupSetting.WeightedPolicies` spreads uploads across the group's allowed policies by free capacity: `pickByFreeCapacity` picks the member with the most remaining `MaxTotalSize` headroom that fits the file (uncapped/suspended members not weighed); explicit directory/user preferences still win; size-aware `getPreferredPolicyForSize` wired into both upload paths; admin group editor gains a switch, en+zh locales - [x] Download source typing + torrent bomb guard (upstream #2178 离线下载) — `CreateDownloadTask` distinguishes plain URLs from BitTorrent sources: `src_file` must name a `.torrent`, `magnet:` links auto-pick a BT-capable provider (qBittorrent preferred, aria2 fallback) and fail fast when only non-BT nodes exist; explicit `provider=ytdlp` with a torrent source is rejected; `validateFiles` caps selected files per task at `maxDownloadFiles` (10k) with `queue.CriticalErr` so crafted torrents cannot flood the entity table +- [x] WeChat scan login (upstream #2729 item 2) — `GET /session/wechat/login` redirects to `open.weixin.qq.com/connect/qrconnect` (scope `snsapi_login`, `#wechat_redirect` fragment); callback exchanges the code at `sns/oauth2/access_token` and binds by unionid (openid fallback); shares the single-use SSO state/ticket machinery and `sso_binding` table; provisioned accounts use synthetic `@connect.wechat.local` addresses with nickname from `/sns/userinfo`; account linking via `?link=1` + unbind via the shared provider route; admin UserSession section gains a WeChat accordion (enabled/AppID/AppSecret/register-enabled, callback URL shown); login page + security settings gain WeChat buttons; en+zh locales ## 6. Phase D — desktop, all platforms diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 6269e4c9..18ef379e 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -833,7 +833,7 @@ "disablePreview": "Disable preview", "enablePreview": "Enable preview", "cancelShare": "Cancel share", - "sharePassword": "Share password", + "sharePassword": "Share password (optional)", "readmeError": "Cannot load README: {{msg}}", "enterKeywords": "Please enter search keywords.", "searchResult": "Search results", @@ -854,7 +854,6 @@ "saveShareLinkHint": "Paste a share link like https://example.com/s/abc123", "shareLink": "Share link", "savedAs": "Saved as (optional)", - "sharePassword": "Share password (optional)", "sharePasswordRequired": "This share requires a password.", "invalidShareLink": "Cannot parse this share link." }, @@ -940,6 +939,8 @@ "unlinkAccount": "Unlink", "unlinkAccountConfirm": "Are you sure you want to unlink this account? You will no longer be able to sign in with it.", "linkQQAccount": "Link QQ account", + "providerWeChat": "WeChat", + "linkWeChatAccount": "Link WeChat account", "nickNameDes": "This is your public display name. It can be your real name or a pseudonym.", "changeEmail": "Change", "changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.", diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 87ea6dcf..2ea44c53 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -877,6 +877,14 @@ "qqCallbackUrlDes": "Register this URL as the website callback domain/URL in the QQ Connect console: <0>{{url}}", "qqRegisterEnabled": "Allow automatic registration", "qqRegisterEnabledDes": "Automatically create a local account when a user signs in via QQ for the first time. QQ supplies no email address, so provisioned accounts use a synthetic @connect.qq.local address.", + "wechatConnect": "WeChat scan login", + "wechatAppID": "App ID", + "wechatAppIDDes": "The AppID of the website application created in the <0>WeChat Open Platform (open.weixin.qq.com).", + "wechatAppSecret": "App Secret", + "wechatCallbackUrl": "Callback URL", + "wechatCallbackUrlDes": "Register this URL's domain as the authorization callback domain in the WeChat Open Platform console: <0>{{url}}", + "wechatRegisterEnabled": "Allow automatic registration", + "wechatRegisterEnabledDes": "Automatically create a local account when a user signs in via WeChat for the first time. WeChat supplies no email address, so provisioned accounts use a synthetic @connect.wechat.local address.", "themeVisible": "Visible", "shareDefaultPrivate": "Private share by default", "shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index b829d8b6..614cafb2 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -833,7 +833,7 @@ "disablePreview": "禁止预览", "enablePreview": "允许预览", "cancelShare": "取消分享", - "sharePassword": "分享密码", + "sharePassword": "分享密码(可选)", "readmeError": "无法读取 README 内容:{{msg}}", "enterKeywords": "请输入搜索关键词", "searchResult": "搜索结果", @@ -854,7 +854,6 @@ "saveShareLinkHint": "粘贴分享链接,例如 https://example.com/s/abc123", "shareLink": "分享链接", "savedAs": "保存名称(可选)", - "sharePassword": "分享密码(可选)", "sharePasswordRequired": "此分享需要密码。", "invalidShareLink": "无法解析此分享链接。" }, @@ -940,6 +939,8 @@ "unlinkAccount": "解绑", "unlinkAccountConfirm": "确定要解绑此账号吗?解绑后将无法再使用该账号登录。", "linkQQAccount": "绑定 QQ 账号", + "providerWeChat": "微信", + "linkWeChatAccount": "绑定微信账号", "nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称", "changeEmail": "更换", "changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 89f1c28b..c53bac05 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -877,6 +877,14 @@ "qqCallbackUrlDes": "请在 QQ 互联控制台中将此 URL 注册为网站回调域/回调地址:<0>{{url}}", "qqRegisterEnabled": "允许自动注册", "qqRegisterEnabledDes": "用户首次通过 QQ 登录时自动创建本地账号。QQ 不提供邮箱,自动注册的账号使用 @connect.qq.local 合成邮箱。", + "wechatConnect": "微信扫码登录", + "wechatAppID": "App ID", + "wechatAppIDDes": "在 <0>微信开放平台(open.weixin.qq.com)创建的网站应用的 AppID。", + "wechatAppSecret": "App Secret", + "wechatCallbackUrl": "回调地址", + "wechatCallbackUrlDes": "请在微信开放平台控制台中将此 URL 的域名注册为授权回调域:<0>{{url}}", + "wechatRegisterEnabled": "允许自动注册", + "wechatRegisterEnabledDes": "用户首次通过微信登录时自动创建本地账号。微信不提供邮箱,自动注册的账号使用 @connect.wechat.local 合成邮箱。", "themeVisible": "可见", "shareDefaultPrivate": "默认私密分享", "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。", diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index 697ea048..5b6af9d1 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -33,6 +33,7 @@ export interface SiteConfig { sso_display_name?: string; sso_auto_redirect?: boolean; qq_connect_enabled?: boolean; + wechat_connect_enabled?: boolean; download_cdn_routes?: { name: string; url: string }[]; download_cdn_shuffle?: boolean; abuse_captcha?: boolean; diff --git a/frontend/src/component/Admin/Settings/Settings.tsx b/frontend/src/component/Admin/Settings/Settings.tsx index 84911fa8..abf5e6bd 100644 --- a/frontend/src/component/Admin/Settings/Settings.tsx +++ b/frontend/src/component/Admin/Settings/Settings.tsx @@ -210,6 +210,10 @@ const Settings = () => { "qq_connect_app_id", "qq_connect_app_secret", "qq_connect_register_enabled", + "wechat_connect_enabled", + "wechat_connect_app_id", + "wechat_connect_app_secret", + "wechat_connect_register_enabled", "email_filter_mode", "email_filter_list", "email_disable_subaddress", diff --git a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx index 5fc5698b..c222da3b 100644 --- a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx +++ b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx @@ -14,6 +14,7 @@ import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Se import { SettingContext } from "../SettingWrapper.tsx"; import QQConnectSettings from "./QQConnectSettings.tsx"; import SSOSettings from "./SSOSettings.tsx"; +import WeChatConnectSettings from "./WeChatConnectSettings.tsx"; const UserSession = () => { const { t } = useTranslation("dashboard"); @@ -307,6 +308,9 @@ const UserSession = () => { + + + diff --git a/frontend/src/component/Admin/Settings/UserSession/WeChatConnectSettings.tsx b/frontend/src/component/Admin/Settings/UserSession/WeChatConnectSettings.tsx new file mode 100644 index 00000000..08999bd6 --- /dev/null +++ b/frontend/src/component/Admin/Settings/UserSession/WeChatConnectSettings.tsx @@ -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 ( + + }> + + setSettings({ + wechat_connect_enabled: e.target.checked ? "1" : "0", + }) + } + onClick={(e) => e.stopPropagation()} + /> + } + label={t("settings.wechatConnect")} + /> + + + + + setSettings({ wechat_connect_app_id: e.target.value })} + required={enabled} + /> + + ]} /> + + + + setSettings({ wechat_connect_app_secret: e.target.value })} + type="password" + placeholder={t("oauth.secretRedactedPlaceholder")} + /> + {t("oauth.clientSecretDesExisting")} + + {callbackURL && ( + + + + ]} + /> + + + )} + + + setSettings({ + wechat_connect_register_enabled: e.target.checked ? "1" : "0", + }) + } + /> + } + label={{t("settings.wechatRegisterEnabled")}} + /> + {t("settings.wechatRegisterEnabledDes")} + + + + + ); +}; + +export default WeChatConnectSettings; diff --git a/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx b/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx index 2f6042bb..ea7e2559 100644 --- a/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx +++ b/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx @@ -10,6 +10,7 @@ import MailOutlined from "../../../Icons/MailOutlined.tsx"; import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx"; import QQLoginButton from "../Signin/QQLoginButton.tsx"; import SSOLoginButton from "../Signin/SSOLoginButton.tsx"; +import WeChatLoginButton from "../Signin/WeChatLoginButton.tsx"; import { Control } from "../Signin/SignIn.tsx"; export const LegalLinks = () => { @@ -105,6 +106,7 @@ const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: Ph {authn && } + diff --git a/frontend/src/component/Pages/Login/Signin/WeChatLoginButton.tsx b/frontend/src/component/Pages/Login/Signin/WeChatLoginButton.tsx new file mode 100644 index 00000000..21e7741b --- /dev/null +++ b/frontend/src/component/Pages/Login/Signin/WeChatLoginButton.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/component/Pages/Setting/Security/SecuritySetting.tsx b/frontend/src/component/Pages/Setting/Security/SecuritySetting.tsx index d87594f8..69bbab44 100644 --- a/frontend/src/component/Pages/Setting/Security/SecuritySetting.tsx +++ b/frontend/src/component/Pages/Setting/Security/SecuritySetting.tsx @@ -177,6 +177,8 @@ const linkedAccountProviderName = (t: (key: string) => string, provider: string) switch (provider) { case "qq": return t("setting.providerQQ"); + case "wechat": + return t("setting.providerWeChat"); default: return provider; } @@ -211,7 +213,13 @@ const LinkedAccountItem = ({ - {account.provider === "qq" ? : } + {account.provider === "qq" ? ( + + ) : account.provider === "wechat" ? ( + + ) : ( + + )} { const authEnabled = useAppSelector((s) => s.siteConfig.login.config.authn); const qqConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.qq_connect_enabled); + const wechatConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.wechat_connect_enabled); const resetPwdFormRef = React.createRef(); const [showResetPassword, setShowResetPassword] = useState(false); @@ -610,7 +619,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => { )} - {(qqConnectEnabled || (setting.linked_accounts && setting.linked_accounts.length > 0)) && ( + {(qqConnectEnabled || wechatConnectEnabled || (setting.linked_accounts && setting.linked_accounts.length > 0)) && ( {setting.linked_accounts?.map((account) => ( @@ -629,6 +638,18 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => { {t("setting.linkQQAccount")} )} + {wechatConnectEnabled && !setting.linked_accounts?.some((a) => a.provider === "wechat") && ( + } + onClick={() => { + window.location.href = "/api/v4/session/wechat/login?link=1"; + }} + > + {t("setting.linkWeChatAccount")} + + )} )} diff --git a/inventory/setting.go b/inventory/setting.go index 53bd11fa..4601edfb 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -570,6 +570,10 @@ var DefaultSettings = map[string]string{ "qq_connect_app_id": "", "qq_connect_app_secret": "", "qq_connect_register_enabled": "1", + "wechat_connect_enabled": "0", + "wechat_connect_app_id": "", + "wechat_connect_app_secret": "", + "wechat_connect_register_enabled": "1", "upload_dedup_scope": "owner", "download_cdn_routes": "", "download_cdn_shuffle": "0", @@ -742,6 +746,7 @@ var RedactedSettings = map[string]struct{}{ "oidc_signing_private_key": {}, "sso_client_secret": {}, "qq_connect_app_secret": {}, + "wechat_connect_app_secret": {}, } func init() { diff --git a/inventory/ssobinding.go b/inventory/ssobinding.go index dee6bcd9..f4032d9d 100644 --- a/inventory/ssobinding.go +++ b/inventory/ssobinding.go @@ -29,7 +29,8 @@ type ( // SsoProvider enumerates the external sign-in providers that can appear in // sso_binding rows. const ( - SsoProviderQQ = "qq" + SsoProviderQQ = "qq" + SsoProviderWeChat = "wechat" ) var ( diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 8ddc60c3..f025d0d2 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -261,6 +261,7 @@ type ( // SSO returns the inbound single sign-on (OIDC) settings. SSO(ctx context.Context) *SSO QQConnect(ctx context.Context) *QQConnect + WeChatConnect(ctx context.Context) *WeChatConnect // EmailFilter returns the sign-up email restriction settings. EmailFilter(ctx context.Context) *EmailFilter // ShareDefaults returns the site-wide share defaults applied when a @@ -1014,6 +1015,15 @@ func (s *settingProvider) QQConnect(ctx context.Context) *QQConnect { } } +func (s *settingProvider) WeChatConnect(ctx context.Context) *WeChatConnect { + return &WeChatConnect{ + Enabled: s.getBoolean(ctx, "wechat_connect_enabled", false), + AppID: s.getString(ctx, "wechat_connect_app_id", ""), + AppSecret: s.getString(ctx, "wechat_connect_app_secret", ""), + RegisterEnabled: s.getBoolean(ctx, "wechat_connect_register_enabled", true), + } +} + func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter { mode := EmailFilterMode(s.getInt(ctx, "email_filter_mode", 0)) if mode < EmailFilterDisabled || mode > EmailFilterBlacklist { diff --git a/pkg/setting/types.go b/pkg/setting/types.go index b6c72f8c..e3b49301 100644 --- a/pkg/setting/types.go +++ b/pkg/setting/types.go @@ -95,6 +95,16 @@ type QQConnect struct { RegisterEnabled bool } +// WeChatConnect holds the WeChat Open Platform (open.weixin.qq.com) scan +// login config. Identity resolves through the token response's unionid, +// falling back to openid. +type WeChatConnect struct { + Enabled bool + AppID string + AppSecret string + RegisterEnabled bool +} + type EmailFilterMode int const ( diff --git a/routers/controllers/user.go b/routers/controllers/user.go index a0da2689..03d1f765 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -385,6 +385,19 @@ func UserQQCallback(c *gin.Context) { service.Callback(c) } +// UserWeChatLogin redirects the browser to the WeChat scan authorization page. +func UserWeChatLogin(c *gin.Context) { + service := ParametersFromContext[*user.WeChatLoginService](c, user.WeChatLoginParameterCtx{}) + service.Login(c) +} + +// UserWeChatCallback completes the WeChat flow and redirects either to the +// SPA ticket handoff or, for link mode, back to the security settings tab. +func UserWeChatCallback(c *gin.Context) { + service := ParametersFromContext[*user.WeChatCallbackService](c, user.WeChatCallbackParameterCtx{}) + service.Callback(c) +} + // UserUnbindSso removes the caller's external-account binding at a provider. func UserUnbindSso(c *gin.Context) { service := ParametersFromContext[*user.SsoUnbindService](c, user.SsoUnbindParameterCtx{}) diff --git a/routers/router.go b/routers/router.go index 088f3b86..8396c890 100644 --- a/routers/router.go +++ b/routers/router.go @@ -355,6 +355,19 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } + // WeChat Open Platform scan login (non-OIDC OAuth2 provider) + wechatRouter := session.Group("wechat") + { + wechatRouter.GET("login", + controllers.FromQuery[usersvc.WeChatLoginService](usersvc.WeChatLoginParameterCtx{}), + controllers.UserWeChatLogin, + ) + wechatRouter.GET("callback", + controllers.FromQuery[usersvc.WeChatCallbackService](usersvc.WeChatCallbackParameterCtx{}), + controllers.UserWeChatCallback, + ) + } + oauthRouter := session.Group("oauth") { oauthRouter.GET("app/:app_id", diff --git a/service/basic/site.go b/service/basic/site.go index 979a4164..f282120d 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -54,6 +54,7 @@ type SiteConfig struct { SSODisplayName string `json:"sso_display_name,omitempty"` SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"` QQConnectEnabled bool `json:"qq_connect_enabled,omitempty"` + WeChatEnabled bool `json:"wechat_connect_enabled,omitempty"` // DownloadCDNRoutes exposes configured CDN mirror endpoints so clients // can offer a download-route picker (#2987). @@ -136,6 +137,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { legalDocs := settings.LegalDocuments(c) sso := settings.SSO(c) qq := settings.QQConnect(c) + wx := settings.WeChatConnect(c) return &SiteConfig{ LoginCaptcha: settings.LoginCaptchaEnabled(c), RegCaptcha: settings.RegCaptchaEnabled(c), @@ -149,6 +151,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { SSODisplayName: sso.DisplayName, SSOAutoRedirect: sso.AutoRedirect, QQConnectEnabled: qq.Enabled && qq.AppID != "", + WeChatEnabled: wx.Enabled && wx.AppID != "", }, nil case "explorer": explorerSettings := settings.ExplorerFrontendSettings(c) diff --git a/service/user/qq.go b/service/user/qq.go index c67db160..71560a8b 100644 --- a/service/user/qq.go +++ b/service/user/qq.go @@ -268,7 +268,7 @@ func (service *QQCallbackService) Callback(c *gin.Context) { func (service *SsoUnbindService) Delete(c *gin.Context) error { dep := dependency.FromContext(c) u := inventory.UserFromContext(c) - if service.Provider != inventory.SsoProviderQQ { + if service.Provider != inventory.SsoProviderQQ && service.Provider != inventory.SsoProviderWeChat { return serializer.NewError(serializer.CodeParamErr, "Unknown provider", nil) } diff --git a/service/user/wechat.go b/service/user/wechat.go new file mode 100644 index 00000000..22a95a90 --- /dev/null +++ b/service/user/wechat.go @@ -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), + }) +} diff --git a/service/user/wechat_test.go b/service/user/wechat_test.go new file mode 100644 index 00000000..9daecac4 --- /dev/null +++ b/service/user/wechat_test.go @@ -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) +}