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
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
parent 1b9a6850ef
commit c7a1b7f13d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 `<key>_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] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `<key>_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] 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] 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 ## 6. Phase D — desktop, all platforms

@ -833,7 +833,7 @@
"disablePreview": "Disable preview", "disablePreview": "Disable preview",
"enablePreview": "Enable preview", "enablePreview": "Enable preview",
"cancelShare": "Cancel share", "cancelShare": "Cancel share",
"sharePassword": "Share password", "sharePassword": "Share password (optional)",
"readmeError": "Cannot load README: {{msg}}", "readmeError": "Cannot load README: {{msg}}",
"enterKeywords": "Please enter search keywords.", "enterKeywords": "Please enter search keywords.",
"searchResult": "Search results", "searchResult": "Search results",
@ -854,7 +854,6 @@
"saveShareLinkHint": "Paste a share link like https://example.com/s/abc123", "saveShareLinkHint": "Paste a share link like https://example.com/s/abc123",
"shareLink": "Share link", "shareLink": "Share link",
"savedAs": "Saved as (optional)", "savedAs": "Saved as (optional)",
"sharePassword": "Share password (optional)",
"sharePasswordRequired": "This share requires a password.", "sharePasswordRequired": "This share requires a password.",
"invalidShareLink": "Cannot parse this share link." "invalidShareLink": "Cannot parse this share link."
}, },
@ -940,6 +939,8 @@
"unlinkAccount": "Unlink", "unlinkAccount": "Unlink",
"unlinkAccountConfirm": "Are you sure you want to unlink this account? You will no longer be able to sign in with it.", "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", "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.", "nickNameDes": "This is your public display name. It can be your real name or a pseudonym.",
"changeEmail": "Change", "changeEmail": "Change",
"changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.", "changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.",

@ -877,6 +877,14 @@
"qqCallbackUrlDes": "Register this URL as the website callback domain/URL in the QQ Connect console: <0>{{url}}</0>", "qqCallbackUrlDes": "Register this URL as the website callback domain/URL in the QQ Connect console: <0>{{url}}</0>",
"qqRegisterEnabled": "Allow automatic registration", "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.", "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</0> (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}}</0>",
"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", "themeVisible": "Visible",
"shareDefaultPrivate": "Private share by default", "shareDefaultPrivate": "Private share by default",
"shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.", "shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.",

@ -833,7 +833,7 @@
"disablePreview": "禁止预览", "disablePreview": "禁止预览",
"enablePreview": "允许预览", "enablePreview": "允许预览",
"cancelShare": "取消分享", "cancelShare": "取消分享",
"sharePassword": "分享密码", "sharePassword": "分享密码(可选)",
"readmeError": "无法读取 README 内容:{{msg}}", "readmeError": "无法读取 README 内容:{{msg}}",
"enterKeywords": "请输入搜索关键词", "enterKeywords": "请输入搜索关键词",
"searchResult": "搜索结果", "searchResult": "搜索结果",
@ -854,7 +854,6 @@
"saveShareLinkHint": "粘贴分享链接,例如 https://example.com/s/abc123", "saveShareLinkHint": "粘贴分享链接,例如 https://example.com/s/abc123",
"shareLink": "分享链接", "shareLink": "分享链接",
"savedAs": "保存名称(可选)", "savedAs": "保存名称(可选)",
"sharePassword": "分享密码(可选)",
"sharePasswordRequired": "此分享需要密码。", "sharePasswordRequired": "此分享需要密码。",
"invalidShareLink": "无法解析此分享链接。" "invalidShareLink": "无法解析此分享链接。"
}, },
@ -940,6 +939,8 @@
"unlinkAccount": "解绑", "unlinkAccount": "解绑",
"unlinkAccountConfirm": "确定要解绑此账号吗?解绑后将无法再使用该账号登录。", "unlinkAccountConfirm": "确定要解绑此账号吗?解绑后将无法再使用该账号登录。",
"linkQQAccount": "绑定 QQ 账号", "linkQQAccount": "绑定 QQ 账号",
"providerWeChat": "微信",
"linkWeChatAccount": "绑定微信账号",
"nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称", "nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称",
"changeEmail": "更换", "changeEmail": "更换",
"changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。", "changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。",

@ -877,6 +877,14 @@
"qqCallbackUrlDes": "请在 QQ 互联控制台中将此 URL 注册为网站回调域/回调地址:<0>{{url}}</0>", "qqCallbackUrlDes": "请在 QQ 互联控制台中将此 URL 注册为网站回调域/回调地址:<0>{{url}}</0>",
"qqRegisterEnabled": "允许自动注册", "qqRegisterEnabled": "允许自动注册",
"qqRegisterEnabledDes": "用户首次通过 QQ 登录时自动创建本地账号。QQ 不提供邮箱,自动注册的账号使用 @connect.qq.local 合成邮箱。", "qqRegisterEnabledDes": "用户首次通过 QQ 登录时自动创建本地账号。QQ 不提供邮箱,自动注册的账号使用 @connect.qq.local 合成邮箱。",
"wechatConnect": "微信扫码登录",
"wechatAppID": "App ID",
"wechatAppIDDes": "在 <0>微信开放平台</0>(open.weixin.qq.com)创建的网站应用的 AppID。",
"wechatAppSecret": "App Secret",
"wechatCallbackUrl": "回调地址",
"wechatCallbackUrlDes": "请在微信开放平台控制台中将此 URL 的域名注册为授权回调域:<0>{{url}}</0>",
"wechatRegisterEnabled": "允许自动注册",
"wechatRegisterEnabledDes": "用户首次通过微信登录时自动创建本地账号。微信不提供邮箱,自动注册的账号使用 @connect.wechat.local 合成邮箱。",
"themeVisible": "可见", "themeVisible": "可见",
"shareDefaultPrivate": "默认私密分享", "shareDefaultPrivate": "默认私密分享",
"shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。", "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。",

@ -33,6 +33,7 @@ export interface SiteConfig {
sso_display_name?: string; sso_display_name?: string;
sso_auto_redirect?: boolean; sso_auto_redirect?: boolean;
qq_connect_enabled?: boolean; qq_connect_enabled?: boolean;
wechat_connect_enabled?: boolean;
download_cdn_routes?: { name: string; url: string }[]; download_cdn_routes?: { name: string; url: string }[];
download_cdn_shuffle?: boolean; download_cdn_shuffle?: boolean;
abuse_captcha?: boolean; abuse_captcha?: boolean;

@ -210,6 +210,10 @@ const Settings = () => {
"qq_connect_app_id", "qq_connect_app_id",
"qq_connect_app_secret", "qq_connect_app_secret",
"qq_connect_register_enabled", "qq_connect_register_enabled",
"wechat_connect_enabled",
"wechat_connect_app_id",
"wechat_connect_app_secret",
"wechat_connect_register_enabled",
"email_filter_mode", "email_filter_mode",
"email_filter_list", "email_filter_list",
"email_disable_subaddress", "email_disable_subaddress",

@ -14,6 +14,7 @@ import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Se
import { SettingContext } from "../SettingWrapper.tsx"; import { SettingContext } from "../SettingWrapper.tsx";
import QQConnectSettings from "./QQConnectSettings.tsx"; import QQConnectSettings from "./QQConnectSettings.tsx";
import SSOSettings from "./SSOSettings.tsx"; import SSOSettings from "./SSOSettings.tsx";
import WeChatConnectSettings from "./WeChatConnectSettings.tsx";
const UserSession = () => { const UserSession = () => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
@ -307,6 +308,9 @@ const UserSession = () => {
<SettingForm lgWidth={5}> <SettingForm lgWidth={5}>
<QQConnectSettings /> <QQConnectSettings />
</SettingForm> </SettingForm>
<SettingForm lgWidth={5}>
<WeChatConnectSettings />
</SettingForm>
</SettingSectionContent> </SettingSectionContent>
</SettingSection> </SettingSection>
<SettingSection> <SettingSection>

@ -0,0 +1,104 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { AccordionDetails, FormControl, FormControlLabel, Switch, Typography } from "@mui/material";
import { useContext, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { isTrueVal } from "../../../../session/utils.ts";
import { Code } from "../../../Common/Code.tsx";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import { NoMarginHelperText, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import { AccordionSummary, StyledAccordion } from "./SSOSettings.tsx";
const WeChatConnectSettings = () => {
const { t } = useTranslation("dashboard");
const { setSettings, values } = useContext(SettingContext);
const callbackURL = useMemo(() => {
const primary = (values.siteURL ?? "").split(",")[0]?.trim().replace(/\/+$/, "");
return primary ? `${primary}/api/v4/session/wechat/callback` : "";
}, [values.siteURL]);
const enabled = isTrueVal(values.wechat_connect_enabled);
return (
<StyledAccordion disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel
control={
<Switch
size="small"
checked={enabled}
onChange={(e) =>
setSettings({
wechat_connect_enabled: e.target.checked ? "1" : "0",
})
}
onClick={(e) => e.stopPropagation()}
/>
}
label={t("settings.wechatConnect")}
/>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<SettingSectionContent>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.wechatAppID")}
value={values.wechat_connect_app_id}
onChange={(e) => setSettings({ wechat_connect_app_id: e.target.value })}
required={enabled}
/>
<NoMarginHelperText>
<Trans i18nKey="settings.wechatAppIDDes" ns="dashboard" components={[<Code key="0" />]} />
</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.wechatAppSecret")}
value={values.wechat_connect_app_secret ?? ""}
onChange={(e) => setSettings({ wechat_connect_app_secret: e.target.value })}
type="password"
placeholder={t("oauth.secretRedactedPlaceholder")}
/>
<NoMarginHelperText>{t("oauth.clientSecretDesExisting")}</NoMarginHelperText>
</FormControl>
{callbackURL && (
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.wechatCallbackUrl")}
value={callbackURL}
slotProps={{ input: { readOnly: true } }}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.wechatCallbackUrlDes"
ns="dashboard"
values={{ url: callbackURL }}
components={[<Code key="0" />]}
/>
</NoMarginHelperText>
</FormControl>
)}
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.wechat_connect_register_enabled)}
onChange={(e) =>
setSettings({
wechat_connect_register_enabled: e.target.checked ? "1" : "0",
})
}
/>
}
label={<Typography variant="body2">{t("settings.wechatRegisterEnabled")}</Typography>}
/>
<NoMarginHelperText>{t("settings.wechatRegisterEnabledDes")}</NoMarginHelperText>
</FormControl>
</SettingSectionContent>
</AccordionDetails>
</StyledAccordion>
);
};
export default WeChatConnectSettings;

@ -10,6 +10,7 @@ import MailOutlined from "../../../Icons/MailOutlined.tsx";
import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx"; import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx";
import QQLoginButton from "../Signin/QQLoginButton.tsx"; import QQLoginButton from "../Signin/QQLoginButton.tsx";
import SSOLoginButton from "../Signin/SSOLoginButton.tsx"; import SSOLoginButton from "../Signin/SSOLoginButton.tsx";
import WeChatLoginButton from "../Signin/WeChatLoginButton.tsx";
import { Control } from "../Signin/SignIn.tsx"; import { Control } from "../Signin/SignIn.tsx";
export const LegalLinks = () => { export const LegalLinks = () => {
@ -105,6 +106,7 @@ const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: Ph
{authn && <PasskeyLoginButton autoComplete onLoginSuccess={onOAuthPasskeyLogin} />} {authn && <PasskeyLoginButton autoComplete onLoginSuccess={onOAuthPasskeyLogin} />}
<SSOLoginButton /> <SSOLoginButton />
<QQLoginButton /> <QQLoginButton />
<WeChatLoginButton />
</Stack> </Stack>
<LegalLinks /> <LegalLinks />
</> </>

@ -0,0 +1,31 @@
import { Icon } from "@iconify/react";
import { Button, ButtonProps } from "@mui/material";
import { useTranslation } from "react-i18next";
import { ApiPrefix } from "../../../../api/request.ts";
import { useAppSelector } from "../../../../redux/hooks.ts";
import { useQuery } from "../../../../util";
export default function WeChatLoginButton(props: ButtonProps) {
const { t } = useTranslation();
const query = useQuery();
const { wechat_connect_enabled } = useAppSelector((state) => state.siteConfig.login.config);
if (!wechat_connect_enabled) {
return null;
}
const startLogin = () => {
const redirect = query.get("redirect");
const target = new URL(ApiPrefix + "/session/wechat/login", window.location.origin);
if (redirect) {
target.searchParams.set("redirect", redirect);
}
window.location.assign(target.toString());
};
return (
<Button fullWidth variant="outlined" startIcon={<Icon icon="ri:wechat-fill" />} onClick={startLogin} {...props}>
{t("login.signInWith", { name: "WeChat" })}
</Button>
);
}

@ -177,6 +177,8 @@ const linkedAccountProviderName = (t: (key: string) => string, provider: string)
switch (provider) { switch (provider) {
case "qq": case "qq":
return t("setting.providerQQ"); return t("setting.providerQQ");
case "wechat":
return t("setting.providerWeChat");
default: default:
return provider; return provider;
} }
@ -211,7 +213,13 @@ const LinkedAccountItem = ({
<StyledOAuthGrantListItem sx={{ pr: "150px" }}> <StyledOAuthGrantListItem sx={{ pr: "150px" }}>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: theme.palette.primary.main }}> <Avatar sx={{ bgcolor: theme.palette.primary.main }}>
{account.provider === "qq" ? <Icon icon="ri:qq-fill" style={{ fontSize: 24 }} /> : <AppsListOutlined />} {account.provider === "qq" ? (
<Icon icon="ri:qq-fill" style={{ fontSize: 24 }} />
) : account.provider === "wechat" ? (
<Icon icon="ri:wechat-fill" style={{ fontSize: 24 }} />
) : (
<AppsListOutlined />
)}
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<StyledListItemText <StyledListItemText
@ -408,6 +416,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
const authEnabled = useAppSelector((s) => s.siteConfig.login.config.authn); const authEnabled = useAppSelector((s) => s.siteConfig.login.config.authn);
const qqConnectEnabled = useAppSelector((s) => s.siteConfig.login.config.qq_connect_enabled); 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<HTMLFormElement>(); const resetPwdFormRef = React.createRef<HTMLFormElement>();
const [showResetPassword, setShowResetPassword] = useState(false); const [showResetPassword, setShowResetPassword] = useState(false);
@ -610,7 +619,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
</List> </List>
</SettingForm> </SettingForm>
)} )}
{(qqConnectEnabled || (setting.linked_accounts && setting.linked_accounts.length > 0)) && ( {(qqConnectEnabled || wechatConnectEnabled || (setting.linked_accounts && setting.linked_accounts.length > 0)) && (
<SettingForm title={t("setting.linkedAccounts")} lgWidth={5}> <SettingForm title={t("setting.linkedAccounts")} lgWidth={5}>
<List disablePadding> <List disablePadding>
{setting.linked_accounts?.map((account) => ( {setting.linked_accounts?.map((account) => (
@ -629,6 +638,18 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
{t("setting.linkQQAccount")} {t("setting.linkQQAccount")}
</SecondaryButton> </SecondaryButton>
)} )}
{wechatConnectEnabled && !setting.linked_accounts?.some((a) => a.provider === "wechat") && (
<SecondaryButton
sx={{ mt: 1 }}
variant={"contained"}
startIcon={<Icon icon="ri:wechat-fill" />}
onClick={() => {
window.location.href = "/api/v4/session/wechat/login?link=1";
}}
>
{t("setting.linkWeChatAccount")}
</SecondaryButton>
)}
</SettingForm> </SettingForm>
)} )}
<VaultSetting setting={setting} setSetting={setSetting} /> <VaultSetting setting={setting} setSetting={setSetting} />

@ -570,6 +570,10 @@ var DefaultSettings = map[string]string{
"qq_connect_app_id": "", "qq_connect_app_id": "",
"qq_connect_app_secret": "", "qq_connect_app_secret": "",
"qq_connect_register_enabled": "1", "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", "upload_dedup_scope": "owner",
"download_cdn_routes": "", "download_cdn_routes": "",
"download_cdn_shuffle": "0", "download_cdn_shuffle": "0",
@ -742,6 +746,7 @@ var RedactedSettings = map[string]struct{}{
"oidc_signing_private_key": {}, "oidc_signing_private_key": {},
"sso_client_secret": {}, "sso_client_secret": {},
"qq_connect_app_secret": {}, "qq_connect_app_secret": {},
"wechat_connect_app_secret": {},
} }
func init() { func init() {

@ -29,7 +29,8 @@ type (
// SsoProvider enumerates the external sign-in providers that can appear in // SsoProvider enumerates the external sign-in providers that can appear in
// sso_binding rows. // sso_binding rows.
const ( const (
SsoProviderQQ = "qq" SsoProviderQQ = "qq"
SsoProviderWeChat = "wechat"
) )
var ( var (

@ -261,6 +261,7 @@ type (
// SSO returns the inbound single sign-on (OIDC) settings. // SSO returns the inbound single sign-on (OIDC) settings.
SSO(ctx context.Context) *SSO SSO(ctx context.Context) *SSO
QQConnect(ctx context.Context) *QQConnect QQConnect(ctx context.Context) *QQConnect
WeChatConnect(ctx context.Context) *WeChatConnect
// EmailFilter returns the sign-up email restriction settings. // EmailFilter returns the sign-up email restriction settings.
EmailFilter(ctx context.Context) *EmailFilter EmailFilter(ctx context.Context) *EmailFilter
// ShareDefaults returns the site-wide share defaults applied when a // 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 { func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter {
mode := EmailFilterMode(s.getInt(ctx, "email_filter_mode", 0)) mode := EmailFilterMode(s.getInt(ctx, "email_filter_mode", 0))
if mode < EmailFilterDisabled || mode > EmailFilterBlacklist { if mode < EmailFilterDisabled || mode > EmailFilterBlacklist {

@ -95,6 +95,16 @@ type QQConnect struct {
RegisterEnabled bool 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 type EmailFilterMode int
const ( const (

@ -385,6 +385,19 @@ func UserQQCallback(c *gin.Context) {
service.Callback(c) 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. // UserUnbindSso removes the caller's external-account binding at a provider.
func UserUnbindSso(c *gin.Context) { func UserUnbindSso(c *gin.Context) {
service := ParametersFromContext[*user.SsoUnbindService](c, user.SsoUnbindParameterCtx{}) service := ParametersFromContext[*user.SsoUnbindService](c, user.SsoUnbindParameterCtx{})

@ -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 := session.Group("oauth")
{ {
oauthRouter.GET("app/:app_id", oauthRouter.GET("app/:app_id",

@ -54,6 +54,7 @@ type SiteConfig struct {
SSODisplayName string `json:"sso_display_name,omitempty"` SSODisplayName string `json:"sso_display_name,omitempty"`
SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"` SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"`
QQConnectEnabled bool `json:"qq_connect_enabled,omitempty"` QQConnectEnabled bool `json:"qq_connect_enabled,omitempty"`
WeChatEnabled bool `json:"wechat_connect_enabled,omitempty"`
// DownloadCDNRoutes exposes configured CDN mirror endpoints so clients // DownloadCDNRoutes exposes configured CDN mirror endpoints so clients
// can offer a download-route picker (#2987). // can offer a download-route picker (#2987).
@ -136,6 +137,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
legalDocs := settings.LegalDocuments(c) legalDocs := settings.LegalDocuments(c)
sso := settings.SSO(c) sso := settings.SSO(c)
qq := settings.QQConnect(c) qq := settings.QQConnect(c)
wx := settings.WeChatConnect(c)
return &SiteConfig{ return &SiteConfig{
LoginCaptcha: settings.LoginCaptchaEnabled(c), LoginCaptcha: settings.LoginCaptchaEnabled(c),
RegCaptcha: settings.RegCaptchaEnabled(c), RegCaptcha: settings.RegCaptchaEnabled(c),
@ -149,6 +151,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
SSODisplayName: sso.DisplayName, SSODisplayName: sso.DisplayName,
SSOAutoRedirect: sso.AutoRedirect, SSOAutoRedirect: sso.AutoRedirect,
QQConnectEnabled: qq.Enabled && qq.AppID != "", QQConnectEnabled: qq.Enabled && qq.AppID != "",
WeChatEnabled: wx.Enabled && wx.AppID != "",
}, nil }, nil
case "explorer": case "explorer":
explorerSettings := settings.ExplorerFrontendSettings(c) explorerSettings := settings.ExplorerFrontendSettings(c)

@ -268,7 +268,7 @@ func (service *QQCallbackService) Callback(c *gin.Context) {
func (service *SsoUnbindService) Delete(c *gin.Context) error { func (service *SsoUnbindService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c) dep := dependency.FromContext(c)
u := inventory.UserFromContext(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) return serializer.NewError(serializer.CodeParamErr, "Unknown provider", nil)
} }

@ -0,0 +1,347 @@
package user
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/gin-gonic/gin"
)
type (
// WeChatLoginParameterCtx marks the WeChat scan-login start route.
WeChatLoginParameterCtx struct{}
// WeChatCallbackParameterCtx marks the WeChat redirect target.
WeChatCallbackParameterCtx struct{}
// WeChatLoginService starts the WeChat Open Platform scan flow.
// `link=1` binds the WeChat identity to the currently signed-in user
// instead of signing in.
WeChatLoginService struct {
Redirect string `form:"redirect"`
Link bool `form:"link"`
}
// WeChatCallbackService completes the WeChat flow.
WeChatCallbackService struct {
Code string `form:"code"`
State string `form:"state"`
}
)
const (
wechatAuthorizeEndpoint = "https://open.weixin.qq.com/connect/qrconnect"
wechatTokenEndpoint = "https://api.weixin.qq.com/sns/oauth2/access_token"
wechatUserInfoEndpoint = "https://api.weixin.qq.com/sns/userinfo"
// wechatConnectMailDomain is the synthetic domain used for provisioned
// accounts; WeChat exposes no email claim so a real address cannot exist.
wechatConnectMailDomain = "connect.wechat.local"
)
// wechatTokenResponse is the JSON payload of /sns/oauth2/access_token.
// unionid is the stable identity across the operator's WeChat apps and is
// preferred over openid when present.
type wechatTokenResponse struct {
AccessToken string `json:"access_token"`
OpenID string `json:"openid"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// wechatUserInfoResponse is the profile document from /sns/userinfo.
type wechatUserInfoResponse struct {
Nickname string `json:"nickname"`
ErrCode int `json:"errcode"`
}
func validateWeChatConfig(wx *setting.WeChatConnect) error {
if !wx.Enabled || wx.AppID == "" || wx.AppSecret == "" {
return errors.New("wechat connect not enabled or not configured")
}
return nil
}
func wechatCallbackURL(settings setting.Provider, c *gin.Context) string {
return settings.SiteURL(c).ResolveReference(&url.URL{Path: "api/v4/session/wechat/callback"}).String()
}
// Login redirects the browser to the WeChat scan QR page.
func (service *WeChatLoginService) Login(c *gin.Context) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
wx := settings.WeChatConnect(c)
if err := validateWeChatConfig(wx); err != nil {
redirectToSigninWithError(c, settings, "sso_not_configured")
return
}
state := ssoState{
Redirect: sanitizeSSORedirect(service.Redirect),
}
if service.Link {
u := inventory.UserFromContext(c)
if inventory.IsAnonymousUser(u) {
redirectToSigninWithError(c, settings, "sso_state_failed")
return
}
state.LinkUserID = u.ID
}
stateKey := ssoStateKey()
if err := dep.KV().Set(stateKey, state, ssoStateTTL); err != nil {
dep.Logger().Warning("Failed to persist WeChat state: %s", err)
redirectToSigninWithError(c, settings, "sso_state_failed")
return
}
authorize, _ := url.Parse(wechatAuthorizeEndpoint)
q := authorize.Query()
q.Set("appid", wx.AppID)
q.Set("redirect_uri", wechatCallbackURL(settings, c))
q.Set("response_type", "code")
q.Set("scope", "snsapi_login")
q.Set("state", stateKey)
authorize.RawQuery = q.Encode()
// The WeChat qrconnect page requires the trailing #wechat_redirect
// fragment; without it the QR code is not rendered.
authorize.Fragment = "wechat_redirect"
c.Redirect(http.StatusFound, authorize.String())
}
// Callback exchanges the code, resolves the WeChat unionid/openid, then
// either binds it to the linking user or signs the bound account in.
func (service *WeChatCallbackService) Callback(c *gin.Context) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
fail := func(code string) {
redirectToSigninWithError(c, settings, code)
}
wx := settings.WeChatConnect(c)
if err := validateWeChatConfig(wx); err != nil {
fail("sso_not_configured")
return
}
if service.State == "" || service.Code == "" {
fail("sso_invalid_response")
return
}
rawState, ok := dep.KV().Get(service.State)
if !ok {
fail("sso_state_expired")
return
}
_ = dep.KV().Delete("", service.State)
state, ok := rawState.(ssoState)
if !ok {
fail("sso_state_expired")
return
}
// The endpoints are constants, but DNS resolution is not: reject a
// weixin host that resolves to a private address before issuing
// credentialed requests.
for _, endpoint := range []string{wechatTokenEndpoint, wechatUserInfoEndpoint} {
if err := request.ValidateExternalURL(c, endpoint, request.SSRFOptions{}); err != nil {
dep.Logger().Warning("WeChat endpoint rejected by SSRF check: %s", err)
fail("sso_exchange_failed")
return
}
}
httpClient := dep.RequestClient()
token, err := wechatExchangeCode(c, httpClient, service.Code, wx)
if err != nil {
dep.Logger().Warning("WeChat token exchange failed: %s", err)
fail("sso_exchange_failed")
return
}
// Prefer unionid: it identifies the same person across every app in the
// operator's WeChat open-platform account.
identity := token.UnionID
if identity == "" {
identity = token.OpenID
}
if identity == "" {
fail("sso_token_invalid")
return
}
bindings := dep.SsoBindingClient()
// Link mode: attach the identity to the signed-in user and return to the
// security settings tab.
if state.LinkUserID != 0 {
if _, err := bindings.Bind(c, state.LinkUserID, inventory.SsoProviderWeChat, identity); err != nil {
dep.Logger().Info("WeChat link rejected: %s", err)
if errors.Is(err, inventory.ErrSsoBindingConflict) {
fail("sso_account_unavailable")
return
}
fail("sso_state_failed")
return
}
recordUserEvent(c, dep, state.LinkUserID, types.EventLinkAccount, map[string]any{"provider": inventory.SsoProviderWeChat})
dest := settings.SiteURL(c).ResolveReference(&url.URL{Path: "settings", RawQuery: "tab=security"})
c.Redirect(http.StatusFound, dest.String())
return
}
var targetUser *ent.User
binding, err := bindings.Get(c, inventory.SsoProviderWeChat, identity)
switch {
case err == nil:
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
targetUser, err = dep.UserClient().GetByID(ctx, binding.UserID)
if err != nil {
dep.Logger().Warning("WeChat binding resolved to missing user %d: %s", binding.UserID, err)
fail("sso_account_unavailable")
return
}
case ent.IsNotFound(err):
if !wx.RegisterEnabled {
dep.Logger().Info("WeChat login rejected: provisioning disabled")
fail("sso_account_unavailable")
return
}
targetUser, err = wechatProvisionUser(c, dep, httpClient, token, wx)
if err != nil {
dep.Logger().Warning("WeChat provisioning failed: %s", err)
fail("sso_account_unavailable")
return
}
if _, err := bindings.Bind(c, targetUser.ID, inventory.SsoProviderWeChat, identity); err != nil {
dep.Logger().Warning("WeChat binding failed: %s", err)
fail("sso_state_failed")
return
}
default:
dep.Logger().Warning("WeChat binding lookup failed: %s", err)
fail("sso_state_failed")
return
}
if targetUser, err = dep.UserClient().LiftExpiredBan(c, targetUser); err != nil {
fail("sso_account_unavailable")
return
}
if err := checkUserStatus(c, targetUser); err != nil {
dep.Logger().Info("WeChat login rejected: %s", err)
fail("sso_account_unavailable")
return
}
ticket := ssoTicketKey()
if err := dep.KV().Set(ticket, targetUser.ID, ssoTicketTTL); err != nil {
fail("sso_state_failed")
return
}
callback := settings.SiteURL(c).ResolveReference(&url.URL{Path: "callback/sso"})
q := callback.Query()
q.Set("ticket", ticket)
if state.Redirect != "" {
q.Set("redirect", state.Redirect)
}
callback.RawQuery = q.Encode()
c.Redirect(http.StatusFound, callback.String())
}
// wechatExchangeCode trades the authorization code for an access token.
// Unlike QQ, WeChat answers with JSON; the token response already carries
// openid and unionid.
func wechatExchangeCode(c *gin.Context, client request.Client, code string, wx *setting.WeChatConnect) (*wechatTokenResponse, error) {
endpoint, _ := url.Parse(wechatTokenEndpoint)
q := endpoint.Query()
q.Set("appid", wx.AppID)
q.Set("secret", wx.AppSecret)
q.Set("code", code)
q.Set("grant_type", "authorization_code")
endpoint.RawQuery = q.Encode()
resp, err := client.
Request(http.MethodGet, endpoint.String(), nil,
request.WithContext(c),
request.WithTimeout(15*time.Second),
).
CheckHTTPResponse(http.StatusOK).
GetResponse()
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
return parseWeChatToken(resp)
}
// parseWeChatToken unwraps the JSON token reply. WeChat reports failures as
// HTTP 200 with a nonzero errcode, so both paths must be checked.
func parseWeChatToken(body string) (*wechatTokenResponse, error) {
var token wechatTokenResponse
if err := json.Unmarshal([]byte(body), &token); err != nil {
return nil, fmt.Errorf("malformed token response: %w", err)
}
if token.ErrCode != 0 || token.AccessToken == "" {
return nil, fmt.Errorf("token error %d: %s", token.ErrCode, token.ErrMsg)
}
return &token, nil
}
// wechatProvisionUser creates the local account for a new WeChat identity.
// WeChat has no email claim, so a synthetic address under
// connect.wechat.local is used; the nickname comes from userinfo and falls
// back to an openid suffix.
func wechatProvisionUser(c *gin.Context, dep dependency.Dep, client request.Client, token *wechatTokenResponse, wx *setting.WeChatConnect) (*ent.User, error) {
nick := ""
endpoint, _ := url.Parse(wechatUserInfoEndpoint)
q := endpoint.Query()
q.Set("access_token", token.AccessToken)
q.Set("openid", token.OpenID)
endpoint.RawQuery = q.Encode()
if resp, err := client.
Request(http.MethodGet, endpoint.String(), nil,
request.WithContext(c),
request.WithTimeout(10*time.Second),
).
CheckHTTPResponse(http.StatusOK).
GetResponse(); err == nil {
var info wechatUserInfoResponse
if err := json.Unmarshal([]byte(resp), &info); err == nil && info.ErrCode == 0 {
nick = strings.TrimSpace(info.Nickname)
}
} else {
dep.Logger().Warning("WeChat userinfo request failed: %s", err)
}
if nick == "" {
nick = "WeChat user " + token.OpenID[len(token.OpenID)-min(6, len(token.OpenID)):]
}
if len(nick) > 100 {
nick = nick[:100]
}
return dep.UserClient().Create(c, &inventory.NewUserArgs{
Email: fmt.Sprintf("wx_%s@%s", token.OpenID, wechatConnectMailDomain),
Nick: nick,
Status: user.StatusActive,
GroupID: dep.SettingProvider().DefaultGroup(c),
})
}

@ -0,0 +1,23 @@
package user
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseWeChatToken(t *testing.T) {
token, err := parseWeChatToken(`{"access_token":"AT","openid":"o","unionid":"u"}`)
require.NoError(t, err)
require.Equal(t, "AT", token.AccessToken)
require.Equal(t, "u", token.UnionID)
_, err = parseWeChatToken(`{"errcode":40029,"errmsg":"invalid code"}`)
require.Error(t, err)
_, err = parseWeChatToken(`{"openid":"o"}`) // no access_token
require.Error(t, err)
_, err = parseWeChatToken(`not json`)
require.Error(t, err)
}
Loading…
Cancel
Save