From aeed9904f92bfc06b297f7adb758eab378d017d5 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 18:44:54 +0200 Subject: [PATCH 1/2] feat(sso): inbound OIDC single sign-on + signup email filtering Generic OIDC consumer covering Keycloak, Authentik, Logto and other standards-compliant providers. Authorization-code flow with one-time KV state (replay-safe), RS256 ID-token validation against provider JWKS (issuer/audience/expiry/nonce enforced, key-rotation retry), userinfo fallback for the email claim, and automatic account provisioning honoring group defaults and the email filter. Callback hands the SPA a single-use 60s ticket exchanged via JSON API - tokens never touch URLs. All IdP endpoints SSRF-validated with only the issuer host allowlisted. Client secret is redacted in settings responses and kept when submitted empty. Signup email filtering (whitelist/blacklist domain list + sub-address block) now applies to both classic registration and SSO provisioning; the previously dead admin controls are wired to the real keys. Admin UI: OIDC settings accordion (enable toggle, display name, issuer, client id/secret, extra scopes, callback URL helper, auto-register toggle). Login page gains an SSO button and localized error mapping for every failure stage. /callback/sso exchanges the ticket through the existing multi-account session upsert. Covers upstream asks for SSO/third-party sign-in (B.3). Authored By: TDvorak --- .../public/locales/en-US/application.json | 24 +- frontend/public/locales/en-US/dashboard.json | 12 +- .../public/locales/zh-CN/application.json | 24 +- frontend/public/locales/zh-CN/dashboard.json | 12 +- frontend/src/api/api.ts | 18 + frontend/src/api/site.ts | 2 + .../src/component/Admin/Settings/Settings.tsx | 11 + .../Settings/UserSession/SSOSettings.tsx | 152 ++++- .../Settings/UserSession/UserSession.tsx | 40 +- .../Pages/Login/Phases/PhaseCollectEmail.tsx | 10 +- .../Pages/Login/Signin/SSOCallback.tsx | 64 ++ .../Pages/Login/Signin/SSOLoginButton.tsx | 31 + .../component/Pages/Login/Signin/SignIn.tsx | 9 + frontend/src/router/index.tsx | 5 + inventory/setting.go | 11 + pkg/auth/oidc_consumer.go | 126 ++++ pkg/auth/oidc_consumer_test.go | 103 +++ pkg/setting/provider.go | 62 ++ pkg/setting/types.go | 26 + routers/controllers/user.go | 26 + routers/router.go | 17 + service/admin/site.go | 8 + service/basic/site.go | 5 + service/user/register.go | 7 +- service/user/sso.go | 584 ++++++++++++++++++ service/user/sso_test.go | 137 ++++ 26 files changed, 1476 insertions(+), 50 deletions(-) create mode 100644 frontend/src/component/Pages/Login/Signin/SSOCallback.tsx create mode 100644 frontend/src/component/Pages/Login/Signin/SSOLoginButton.tsx create mode 100644 pkg/auth/oidc_consumer.go create mode 100644 pkg/auth/oidc_consumer_test.go create mode 100644 service/user/sso.go create mode 100644 service/user/sso_test.go diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 7813e9c6..e70152db 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -52,7 +52,22 @@ "signingOut": "Signing out...", "loggedOut": "You are signed out now.", "clickToRefresh": "Click to refresh", - "switchLanguage": "Switch Language" + "switchLanguage": "Switch Language", + "signInWith": "Sign in with {{name}}", + "ssoFailed": "Single sign-on failed. Please try again or use another sign-in method.", + "ssoError": { + "generic": "Single sign-on failed. Please try again.", + "sso_not_configured": "Single sign-on is not configured. Please contact the administrator.", + "sso_discovery_failed": "Failed to reach the identity provider. Please try again later.", + "sso_state_failed": "Failed to start the sign-in session. Please try again.", + "sso_denied": "Sign-in was denied by the identity provider.", + "sso_invalid_response": "Invalid response from the identity provider.", + "sso_state_expired": "The sign-in session has expired. Please try again.", + "sso_exchange_failed": "Failed to complete sign-in with the identity provider.", + "sso_token_invalid": "Failed to verify the identity response.", + "sso_no_email": "The identity provider did not return an email address.", + "sso_account_unavailable": "No account is available for this identity. Registration may be restricted." + } }, "oauth": { "authorize": "Authorize Application", @@ -999,7 +1014,12 @@ "validDurationDays": "{{num}} days", "reportTarget": "Report target", "reportReason": "Reason", - "reportReasonOptions": ["Copyright infringement", "Harmful content", "Spam", "Other"], + "reportReasonOptions": [ + "Copyright infringement", + "Harmful content", + "Spam", + "Other" + ], "reportDescription": "Additional description", "reportAbuseSuccess": "Report submitted." } diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index b8028538..bad878b6 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -820,7 +820,13 @@ "ftsTikaExts": "Supported extensions", "ftsTikaExtsDes": "Comma-separated list of file extensions. Tika will process files of these types.", "ftsTikaMaxFileSize": "Maximum file size", - "ftsTikaMaxFileSizeDes": "Maximum file size to send to Tika for content extraction." + "ftsTikaMaxFileSizeDes": "Maximum file size to send to Tika for content extraction.", + "ssoIssuer": "Issuer URL", + "ssoIssuerDes": "Base URL of the OIDC issuer, e.g. <0>https://keycloak.example.com/realms/master. The provider metadata is fetched from <1>.well-known/openid-configuration under this URL.", + "ssoRegisterEnabled": "Allow automatic registration", + "ssoRegisterEnabledDes": "Automatically create a local account when a user signs in via SSO for the first time. The sign-up email filter below also applies.", + "ssoCallbackUrl": "Callback URL", + "ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}" }, "giftCodes": { "giftCodesSettings": "Gift Codes", @@ -1597,7 +1603,7 @@ "filterEmailProviderDisabled": "Disabled", "filterEmailProviderWhitelist": "Whitelist", "filterEmailProviderBlacklist": "Blacklist", - "filterEmailProviderDes": "Restrict the email provider for registration, third-party SSO login is not restricted.", + "filterEmailProviderDes": "Restrict the email provider for new registrations, including accounts provisioned via SSO.", "filterEmailProviderRule": "Email domain filter rules", "filterEmailProviderRuleDes": "Separate multiple fields with a semi-colon comma.", "qqConnect": "QQ Connect", @@ -1758,4 +1764,4 @@ "shareID": "Shared ID", "reason": "Reason" } -} +} \ No newline at end of file diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 789b5167..1e8d13ab 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -52,7 +52,22 @@ "signingOut": "正在退出登录...", "loggedOut": "您已退出登录", "clickToRefresh": "点击刷新验证码", - "switchLanguage": "切换语言" + "switchLanguage": "切换语言", + "signInWith": "使用 {{name}} 登录", + "ssoFailed": "单点登录失败,请重试或选择其他登录方式。", + "ssoError": { + "generic": "单点登录失败,请重试。", + "sso_not_configured": "单点登录尚未配置,请联系管理员。", + "sso_discovery_failed": "无法连接到身份提供方,请稍后重试。", + "sso_state_failed": "无法创建登录会话,请重试。", + "sso_denied": "身份提供方拒绝了登录请求。", + "sso_invalid_response": "身份提供方返回了无效的响应。", + "sso_state_expired": "登录会话已过期,请重试。", + "sso_exchange_failed": "无法完成与身份提供方的登录交换。", + "sso_token_invalid": "身份响应验证失败。", + "sso_no_email": "身份提供方未返回邮箱地址。", + "sso_account_unavailable": "此身份没有可用账号,注册可能受限。" + } }, "oauth": { "authorize": "授权应用", @@ -999,7 +1014,12 @@ "validDurationDays": "{{num}} 天", "reportTarget": "举报对象", "reportReason": "原因", - "reportReasonOptions": ["侵权", "有害内容", "垃圾信息", "其他"], + "reportReasonOptions": [ + "侵权", + "有害内容", + "垃圾信息", + "其他" + ], "reportDescription": "补充说明", "reportAbuseSuccess": "举报已提交" } diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 8e8a18cf..15867876 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -820,7 +820,13 @@ "ftsTikaExts": "支持的扩展名", "ftsTikaExtsDes": "逗号分隔的文件扩展名列表,Tika 将处理这些类型的文件。", "ftsTikaMaxFileSize": "最大文件大小", - "ftsTikaMaxFileSizeDes": "发送至 Tika 进行内容提取的文件最大大小。" + "ftsTikaMaxFileSizeDes": "发送至 Tika 进行内容提取的文件最大大小。", + "ssoIssuer": "Issuer 地址", + "ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master。将从该地址下的 <1>.well-known/openid-configuration 获取提供方元数据。", + "ssoRegisterEnabled": "允许自动注册", + "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", + "ssoCallbackUrl": "回调地址", + "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}" }, "giftCodes": { "giftCodesSettings": "礼品码", @@ -1596,7 +1602,7 @@ "filterEmailProviderDisabled": "不启用", "filterEmailProviderWhitelist": "白名单", "filterEmailProviderBlacklist": "黑名单", - "filterEmailProviderDes": "只允许使用特定的邮箱注册站点,第三方 SSO 登录不受此限制。", + "filterEmailProviderDes": "限制新注册账号可使用的邮箱提供商,包括通过 SSO 自动创建的账号。", "filterEmailProviderRule": "邮箱域过滤规则", "filterEmailProviderRuleDes": "多个域请使用半角逗号 , 隔开。", "qqConnect": "QQ 互联", @@ -1757,4 +1763,4 @@ "shareID": "分享 ID", "reason": "原因" } -} +} \ No newline at end of file diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 6cc44992..3332518a 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -189,6 +189,24 @@ export function sendLogin(req: PasswordLoginRequest): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/session/sso/exchange", + { + data: { ticket }, + method: "POST", + }, + { + ...defaultOpts, + noCredential: true, + }, + ), + ); + }; +} + export function send2FALogin(req: TwoFALoginRequest): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index c5fcae96..0b1497e4 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -28,6 +28,8 @@ export interface SiteConfig { captcha_cap_secret_key?: string; captcha_cap_asset_server?: string; register_enabled?: boolean; + sso_enabled?: boolean; + sso_display_name?: string; logo?: string; logo_light?: string; tos_url?: string; diff --git a/frontend/src/component/Admin/Settings/Settings.tsx b/frontend/src/component/Admin/Settings/Settings.tsx index e95bfb50..8a038e92 100644 --- a/frontend/src/component/Admin/Settings/Settings.tsx +++ b/frontend/src/component/Admin/Settings/Settings.tsx @@ -191,6 +191,17 @@ const Settings = () => { "avatar_size", "avatar_size_l", "gravatar_server", + "sso_enabled", + "sso_display_name", + "sso_issuer", + "sso_client_id", + "sso_client_secret", + "sso_scopes", + "sso_register_enabled", + "email_filter_mode", + "email_filter_list", + "email_disable_subaddress", + "siteURL", ]} > diff --git a/frontend/src/component/Admin/Settings/UserSession/SSOSettings.tsx b/frontend/src/component/Admin/Settings/UserSession/SSOSettings.tsx index 8d8c6fae..057fb3a1 100644 --- a/frontend/src/component/Admin/Settings/UserSession/SSOSettings.tsx +++ b/frontend/src/component/Admin/Settings/UserSession/SSOSettings.tsx @@ -1,9 +1,13 @@ import { ExpandMoreRounded } from "@mui/icons-material"; -import { Accordion, AccordionDetails, FormControlLabel, styled } from "@mui/material"; +import { Accordion, AccordionDetails, FormControl, FormControlLabel, styled, Switch, Typography } from "@mui/material"; import MuiAccordionSummary, { AccordionSummaryProps } from "@mui/material/AccordionSummary"; -import { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { StyledCheckbox } from "../../../Common/StyledComponents.tsx"; +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"; export const AccordionSummary = styled((props: AccordionSummaryProps) => )( ({ theme }) => ({ @@ -26,38 +30,120 @@ export const StyledAccordion = styled(Accordion)(({ theme }) => ({ }, })); -export interface SettingSectionProps {} - const SSOSettings = () => { - const [open, setOpen] = useState(false); const { t } = useTranslation("dashboard"); - const onClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - setOpen(true); - }, []); + const { setSettings, values } = useContext(SettingContext); + + const callbackURL = useMemo(() => { + const primary = (values.siteURL ?? "").split(",")[0]?.trim().replace(/\/+$/, ""); + return primary ? `${primary}/api/v4/session/sso/callback` : ""; + }, [values.siteURL]); + + const enabled = isTrueVal(values.sso_enabled); + return ( - <> -
- - }> - } label={t("vas.qqConnect")} /> - - - - - }> - } label={t("settings.logto")} /> - - - - - }> - } label={t("settings.oidc")} /> - - - -
- + + }> + + setSettings({ + sso_enabled: e.target.checked ? "1" : "0", + }) + } + onClick={(e) => e.stopPropagation()} + /> + } + label={t("settings.oidc")} + /> + + + + + setSettings({ sso_display_name: e.target.value })} + required + /> + {t("settings.displayNameDes")} + + + setSettings({ sso_issuer: e.target.value })} + placeholder="https://keycloak.example.com/realms/master" + required={enabled} + /> + + , ]} /> + + + + setSettings({ sso_client_id: e.target.value })} + required={enabled} + /> + {t("settings.clientIDDes")} + + + setSettings({ sso_client_secret: e.target.value })} + type="password" + placeholder={t("oauth.secretRedactedPlaceholder")} + /> + {t("oauth.clientSecretDesExisting")} + + + setSettings({ sso_scopes: e.target.value })} + placeholder="groups, roles" + /> + + ]} /> + + + {callbackURL && ( + + + + ]} /> + + + )} + + + setSettings({ + sso_register_enabled: e.target.checked ? "1" : "0", + }) + } + /> + } + label={{t("settings.ssoRegisterEnabled")}} + /> + {t("settings.ssoRegisterEnabledDes")} + + + + ); }; diff --git a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx index b9e24f5d..fb177d9a 100644 --- a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx +++ b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx @@ -141,10 +141,17 @@ const UserSession = () => { - + + setSettings({ + email_filter_mode: e.target.value as string, + }) + } + > {["filterEmailProviderDisabled", "filterEmailProviderWhitelist", "filterEmailProviderBlacklist"].map( (v, i) => ( - + { {t("vas.filterEmailProviderDes")} + {(values.email_filter_mode ?? "0") !== "0" && ( + + + + setSettings({ + email_filter_list: e.target.value, + }) + } + multiline + minRows={3} + placeholder={"example.com\nmail.example.org"} + /> + {t("vas.filterEmailProviderRuleDes")} + + + )} } + control={ + + setSettings({ + email_disable_subaddress: e.target.checked ? "1" : "0", + }) + } + /> + } label={ <> {t("vas.disableSubAddressEmail")} diff --git a/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx b/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx index 423836b2..71e342d3 100644 --- a/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx +++ b/frontend/src/component/Pages/Login/Phases/PhaseCollectEmail.tsx @@ -8,6 +8,7 @@ import { useQuery } from "../../../../util"; import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx"; import MailOutlined from "../../../Icons/MailOutlined.tsx"; import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx"; +import SSOLoginButton from "../Signin/SSOLoginButton.tsx"; import { Control } from "../Signin/SignIn.tsx"; export const LegalLinks = () => { @@ -52,11 +53,11 @@ interface PhaseCollectEmailProps { const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: PhaseCollectEmailProps) => { const { t } = useTranslation(); const query = useQuery(); - const { register_enabled, authn } = useAppSelector((state) => state.siteConfig.login.config); + const { register_enabled, authn, sso_enabled } = useAppSelector((state) => state.siteConfig.login.config); const tos = useAppSelector((state) => state.siteConfig.login.config.tos_url); const privacyPolicy = useAppSelector((state) => state.siteConfig.login.config.privacy_policy_url); - const showFooter = tos || privacyPolicy || authn; + const showFooter = tos || privacyPolicy || authn || sso_enabled; useEffect(() => { if (!!query.get("email")) { @@ -97,7 +98,10 @@ const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: Ph {showFooter && ( <> - {authn && } + + {authn && } + + )} diff --git a/frontend/src/component/Pages/Login/Signin/SSOCallback.tsx b/frontend/src/component/Pages/Login/Signin/SSOCallback.tsx new file mode 100644 index 00000000..a1fe4394 --- /dev/null +++ b/frontend/src/component/Pages/Login/Signin/SSOCallback.tsx @@ -0,0 +1,64 @@ +import { Box, CircularProgress, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { sendSSOExchange } from "../../../../api/api.ts"; +import { setHeadlessFrameLoading } from "../../../../redux/globalStateSlice.ts"; +import { useAppDispatch } from "../../../../redux/hooks.ts"; +import { refreshUserSession } from "../../../../redux/thunks/session.ts"; +import { useQuery } from "../../../../util"; +import DismissCircleFilled from "../../../Icons/DismissCircleFilled.tsx"; + +const SSOCallback = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const query = useQuery(); + const [missingTicket, setMissingTicket] = useState(false); + + useEffect(() => { + dispatch(setHeadlessFrameLoading(true)); + const ticket = query.get("ticket"); + const redirect = query.get("redirect"); + + const finish = async () => { + if (!ticket) { + setMissingTicket(true); + dispatch(setHeadlessFrameLoading(false)); + return; + } + try { + const loginRes = await dispatch(sendSSOExchange(ticket)); + dispatch(refreshUserSession(loginRes, redirect)); + } catch { + navigate("/session?sso_error=sso_exchange_failed", { replace: true }); + } + }; + finish(); + }, []); + + return ( + + {missingTicket ? ( + <> + + theme.palette.error.main, mt: 2 }}> + {t("login.ssoFailed")} + + + ) : ( + + )} + + ); +}; + +export default SSOCallback; diff --git a/frontend/src/component/Pages/Login/Signin/SSOLoginButton.tsx b/frontend/src/component/Pages/Login/Signin/SSOLoginButton.tsx new file mode 100644 index 00000000..229bd5f9 --- /dev/null +++ b/frontend/src/component/Pages/Login/Signin/SSOLoginButton.tsx @@ -0,0 +1,31 @@ +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"; +import Enter from "../../../Icons/Enter.tsx"; + +export default function SSOLoginButton(props: ButtonProps) { + const { t } = useTranslation(); + const query = useQuery(); + const { sso_enabled, sso_display_name } = useAppSelector((state) => state.siteConfig.login.config); + + if (!sso_enabled) { + return null; + } + + const startLogin = () => { + const redirect = query.get("redirect"); + const target = new URL(ApiPrefix + "/session/sso", window.location.origin); + if (redirect) { + target.searchParams.set("redirect", redirect); + } + window.location.assign(target.toString()); + }; + + return ( + + ); +} diff --git a/frontend/src/component/Pages/Login/Signin/SignIn.tsx b/frontend/src/component/Pages/Login/Signin/SignIn.tsx index 36edcf53..cfb05f55 100644 --- a/frontend/src/component/Pages/Login/Signin/SignIn.tsx +++ b/frontend/src/component/Pages/Login/Signin/SignIn.tsx @@ -303,6 +303,15 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => { // Initial load for OAuth or regular login useEffect(() => { + const ssoError = query.get("sso_error"); + if (ssoError) { + enqueueSnackbar({ + message: t(`login.ssoError.${ssoError}`, t("login.ssoError.generic")), + variant: "error", + action: DefaultCloseAction, + }); + } + const init = async () => { if (isOAuthFlow) { const registration = await loadAppRegistration(); diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 8552277b..d3270cdb 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -9,6 +9,7 @@ import Reset from "../component/Pages/Login/Reset.tsx"; import SessionIntro from "../component/Pages/Login/SessionIntro.tsx"; import DesktopCallback from "../component/Pages/Login/Signin/DesktopCallback.tsx"; import MobileCallback from "../component/Pages/Login/Signin/MobileCallback.tsx"; +import SSOCallback from "../component/Pages/Login/Signin/SSOCallback.tsx"; import SignIn from "../component/Pages/Login/Signin/SignIn.tsx"; import SignUp from "../component/Pages/Login/Signup.tsx"; import NoMatch from "../component/Pages/NoMatch.tsx"; @@ -68,6 +69,10 @@ export const router = createBrowserRouter([ path: "ios", element: , }, + { + path: "sso", + element: , + }, ], }, { diff --git a/inventory/setting.go b/inventory/setting.go index 12d21805..e822d9ae 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -551,6 +551,16 @@ var DefaultSettings = map[string]string{ "cron_trash_bin_collect": "@every 33m", "cron_oauth_cred_refresh": "@every 230h", "authn_enabled": "1", + "sso_enabled": "0", + "sso_display_name": "SSO", + "sso_issuer": "", + "sso_client_id": "", + "sso_client_secret": "", + "sso_scopes": "", + "sso_register_enabled": "1", + "email_filter_mode": "0", + "email_filter_list": "", + "email_disable_subaddress": "0", "captcha_type": "normal", "captcha_height": "60", "captcha_width": "240", @@ -710,6 +720,7 @@ var RedactedSettings = map[string]struct{}{ "encrypt_master_key": {}, "secret_key": {}, "oidc_signing_private_key": {}, + "sso_client_secret": {}, } func init() { diff --git a/pkg/auth/oidc_consumer.go b/pkg/auth/oidc_consumer.go new file mode 100644 index 00000000..fde768e9 --- /dev/null +++ b/pkg/auth/oidc_consumer.go @@ -0,0 +1,126 @@ +package auth + +import ( + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "math/big" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrOIDCDiscoveryFailed = errors.New("OIDC discovery failed") + ErrOIDCKeyNotFound = errors.New("no matching key in JWKS") + ErrOIDCInvalidToken = errors.New("invalid OIDC token") +) + +// OIDCDiscovery is the subset of the provider metadata document Cloudreve +// needs to consume an external OIDC identity provider. +type OIDCDiscovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +// OIDCTokenResponse is the token endpoint payload. +type OIDCTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token"` +} + +// OIDCUserInfo is the userinfo endpoint payload subset. +type OIDCUserInfo struct { + Sub string `json:"sub"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Picture string `json:"picture,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` +} + +// Validate checks the discovery document has the endpoints the code flow requires. +func (d *OIDCDiscovery) Validate() error { + if d.Issuer == "" || d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.JWKSURI == "" { + return fmt.Errorf("incomplete discovery document: %w", ErrOIDCDiscoveryFailed) + } + return nil +} + +// VerifyOIDCIDToken validates signature, issuer, audience, expiry and nonce of +// an ID token against the provider's JWKS. Returns ErrOIDCKeyNotFound when the +// signing key is absent so callers can refetch JWKS once for key rotation. +func VerifyOIDCIDToken(idToken, issuer, clientID, nonce string, jwks *JWKSet) (*OIDCIDTokenClaims, error) { + parser := jwt.NewParser() + unverified, _, err := parser.ParseUnverified(idToken, &OIDCIDTokenClaims{}) + if err != nil { + return nil, fmt.Errorf("malformed id_token: %w", err) + } + + kid, _ := unverified.Header["kid"].(string) + key, err := findJWK(jwks, kid) + if err != nil { + return nil, err + } + + claims := &OIDCIDTokenClaims{} + _, err = jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (any, error) { + return key, nil + }, + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithIssuer(issuer), + jwt.WithAudience(clientID), + jwt.WithExpirationRequired(), + ) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrOIDCInvalidToken, err) + } + + if claims.Nonce != nonce { + return nil, fmt.Errorf("nonce mismatch: %w", ErrOIDCInvalidToken) + } + + return claims, nil +} + +// findJWK selects the signing key by kid. A token without kid is only matched +// when the provider publishes a single key. +func findJWK(jwks *JWKSet, kid string) (*rsa.PublicKey, error) { + for _, k := range jwks.Keys { + if k.Kty != "RSA" { + continue + } + if k.Kid == kid || (kid == "" && len(jwks.Keys) == 1) { + return jwkToRSAPublicKey(k) + } + } + + return nil, ErrOIDCKeyNotFound +} + +func jwkToRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, fmt.Errorf("invalid JWK modulus: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("invalid JWK exponent: %w", err) + } + + e := new(big.Int).SetBytes(eBytes).Int64() + if e < 3 || e > int64(1<<31-1) { + return nil, fmt.Errorf("invalid JWK exponent value") + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(e), + }, nil +} diff --git a/pkg/auth/oidc_consumer_test.go b/pkg/auth/oidc_consumer_test.go new file mode 100644 index 00000000..804e0f04 --- /dev/null +++ b/pkg/auth/oidc_consumer_test.go @@ -0,0 +1,103 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func testRSAKeyPEM(t *testing.T) string { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) +} + +func signConsumerToken(t *testing.T, pemKey string, claims *OIDCIDTokenClaims) string { + token, err := SignOIDCIDToken(pemKey, claims) + require.NoError(t, err) + return token +} + +func validClaims() *OIDCIDTokenClaims { + return &OIDCIDTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + Audience: jwt.ClaimStrings{"cloudreve"}, + Subject: "user-1", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + Nonce: "nonce-1", + Email: "user@example.com", + } +} + +func TestVerifyOIDCIDToken(t *testing.T) { + pemKey := testRSAKeyPEM(t) + jwks, err := OIDCJWKSet(pemKey) + require.NoError(t, err) + + t.Run("valid token", func(t *testing.T) { + token := signConsumerToken(t, pemKey, validClaims()) + claims, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "nonce-1", jwks) + require.NoError(t, err) + require.Equal(t, "user@example.com", claims.Email) + require.Equal(t, "user-1", claims.Subject) + }) + + t.Run("wrong issuer", func(t *testing.T) { + token := signConsumerToken(t, pemKey, validClaims()) + _, err := VerifyOIDCIDToken(token, "https://evil.example.com", "cloudreve", "nonce-1", jwks) + require.ErrorIs(t, err, ErrOIDCInvalidToken) + }) + + t.Run("wrong audience", func(t *testing.T) { + token := signConsumerToken(t, pemKey, validClaims()) + _, err := VerifyOIDCIDToken(token, "https://idp.example.com", "other-client", "nonce-1", jwks) + require.ErrorIs(t, err, ErrOIDCInvalidToken) + }) + + t.Run("nonce mismatch", func(t *testing.T) { + token := signConsumerToken(t, pemKey, validClaims()) + _, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "other-nonce", jwks) + require.ErrorIs(t, err, ErrOIDCInvalidToken) + }) + + t.Run("expired token", func(t *testing.T) { + claims := validClaims() + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Hour)) + token := signConsumerToken(t, pemKey, claims) + _, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "nonce-1", jwks) + require.ErrorIs(t, err, ErrOIDCInvalidToken) + }) + + t.Run("forged signature", func(t *testing.T) { + otherPEM := testRSAKeyPEM(t) + token := signConsumerToken(t, otherPEM, validClaims()) + _, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "nonce-1", jwks) + // Foreign key produces a different kid -> key not found in JWKS. + require.ErrorIs(t, err, ErrOIDCKeyNotFound) + }) + + t.Run("malformed token", func(t *testing.T) { + _, err := VerifyOIDCIDToken("not-a-jwt", "https://idp.example.com", "cloudreve", "nonce-1", jwks) + require.Error(t, err) + }) +} + +func TestOIDCDiscoveryValidate(t *testing.T) { + require.NoError(t, (&OIDCDiscovery{ + Issuer: "https://idp.example.com", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + JWKSURI: "https://idp.example.com/jwks", + }).Validate()) + + require.ErrorIs(t, (&OIDCDiscovery{Issuer: "https://idp.example.com"}).Validate(), ErrOIDCDiscoveryFailed) +} diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index cdbc9be5..393927e9 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/url" + "sort" "strconv" "strings" "time" @@ -241,6 +242,10 @@ type ( // ExposeUserEmail returns true if user email should be exposed to other // signed-in users in redacted responses. ExposeUserEmail(ctx context.Context) bool + // SSO returns the inbound single sign-on (OIDC) settings. + SSO(ctx context.Context) *SSO + // EmailFilter returns the sign-up email restriction settings. + EmailFilter(ctx context.Context) *EmailFilter } UseFirstSiteUrlCtxKey = struct{} ) @@ -869,6 +874,63 @@ func (s *settingProvider) RegisterEnabled(ctx context.Context) bool { return s.getBoolean(ctx, "register_enabled", false) } +func (s *settingProvider) SSO(ctx context.Context) *SSO { + // Base scopes are always requested; sso_scopes holds extras only, so the + // admin cannot break the flow by removing "openid". + scopes := map[string]bool{"openid": true, "profile": true, "email": true} + for _, extra := range strings.FieldsFunc(s.getString(ctx, "sso_scopes", ""), func(r rune) bool { + return r == ',' || r == ';' || r == ' ' + }) { + if extra = strings.TrimSpace(extra); extra != "" { + scopes[extra] = true + } + } + scopeList := make([]string, 0, len(scopes)) + for sc := range scopes { + scopeList = append(scopeList, sc) + } + sort.Strings(scopeList) + + issuer := strings.TrimSpace(s.getString(ctx, "sso_issuer", "")) + // Tolerate admins pasting the full well-known document URL. + issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration") + issuer = strings.TrimRight(issuer, "/") + + return &SSO{ + Enabled: s.getBoolean(ctx, "sso_enabled", false), + DisplayName: s.getString(ctx, "sso_display_name", "SSO"), + Issuer: issuer, + ClientID: s.getString(ctx, "sso_client_id", ""), + ClientSecret: s.getString(ctx, "sso_client_secret", ""), + Scopes: strings.Join(scopeList, " "), + RegisterEnabled: s.getBoolean(ctx, "sso_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 { + mode = EmailFilterDisabled + } + + raw := s.getString(ctx, "email_filter_list", "") + var list []string + for _, entry := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == ';' || r == ' ' + }) { + entry = strings.ToLower(strings.TrimSpace(entry)) + if entry != "" { + list = append(list, entry) + } + } + + return &EmailFilter{ + Mode: mode, + List: list, + DisableSubAddress: s.getBoolean(ctx, "email_disable_subaddress", false), + } +} + func (s *settingProvider) ExposeUserEmail(ctx context.Context) bool { return s.getBoolean(ctx, "expose_user_email", true) } diff --git a/pkg/setting/types.go b/pkg/setting/types.go index 7c9b975e..339e533e 100644 --- a/pkg/setting/types.go +++ b/pkg/setting/types.go @@ -72,6 +72,32 @@ type TokenAuth struct { RefreshTokenTTL time.Duration } +// SSO holds inbound single sign-on (OIDC consumer) settings. +type SSO struct { + Enabled bool + DisplayName string + Issuer string + ClientID string + ClientSecret string + Scopes string + RegisterEnabled bool +} + +type EmailFilterMode int + +const ( + EmailFilterDisabled EmailFilterMode = iota + EmailFilterWhitelist + EmailFilterBlacklist +) + +// EmailFilter holds sign-up email restriction settings. +type EmailFilter struct { + Mode EmailFilterMode + List []string + DisableSubAddress bool +} + type DBFS struct { UseCursorPagination bool MaxPageSize int diff --git a/routers/controllers/user.go b/routers/controllers/user.go index 65e08ed1..e4363e73 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -355,6 +355,32 @@ func UserPrepareLogin(c *gin.Context) { c.JSON(200, serializer.Response{Data: res}) } +// UserSSOLogin redirects the browser to the configured OIDC provider. +func UserSSOLogin(c *gin.Context) { + service := ParametersFromContext[*user.SSOLoginService](c, user.SSOLoginParameterCtx{}) + service.SSOLogin(c) +} + +// UserSSOCallback completes the OIDC flow and redirects to the SPA with a +// one-time ticket. +func UserSSOCallback(c *gin.Context) { + service := ParametersFromContext[*user.SSOCallbackService](c, user.SSOCallbackParameterCtx{}) + service.SSOCallback(c) +} + +// UserSSOExchange trades the one-time ticket for a session token pair. +func UserSSOExchange(c *gin.Context) { + service := ParametersFromContext[*user.SSOExchangeService](c, user.SSOExchangeParameterCtx{}) + res, err := service.SSOExchange(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, serializer.Response{Data: res}) +} + // UserSearch Search user by keyword func UserSearch(c *gin.Context) { service := ParametersFromContext[*user.SearchUserService](c, user.SearchUserParamCtx{}) diff --git a/routers/router.go b/routers/router.go index 197e8b46..24ad3606 100644 --- a/routers/router.go +++ b/routers/router.go @@ -319,6 +319,23 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.UserPrepareLogin, ) + // Inbound OIDC single sign-on + ssoRouter := session.Group("sso") + { + ssoRouter.GET("", + controllers.FromQuery[usersvc.SSOLoginService](usersvc.SSOLoginParameterCtx{}), + controllers.UserSSOLogin, + ) + ssoRouter.GET("callback", + controllers.FromQuery[usersvc.SSOCallbackService](usersvc.SSOCallbackParameterCtx{}), + controllers.UserSSOCallback, + ) + ssoRouter.POST("exchange", + controllers.FromJSON[usersvc.SSOExchangeService](usersvc.SSOExchangeParameterCtx{}), + controllers.UserSSOExchange, + ) + } + oauthRouter := session.Group("oauth") { oauthRouter.GET("app/:app_id", diff --git a/service/admin/site.go b/service/admin/site.go index 20498fa1..c03347b2 100644 --- a/service/admin/site.go +++ b/service/admin/site.go @@ -279,6 +279,14 @@ func (s *SetSettingService) SetSetting(c *gin.Context) (map[string]string, error kv := dep.KV() settingClient := dep.SettingClient() + // Redacted secrets are never returned to the client, so an empty value + // means "keep current" — otherwise saving the form would wipe the secret. + for k, v := range s.Settings { + if _, redacted := inventory.RedactedSettings[strings.ToLower(k)]; redacted && v == "" { + delete(s.Settings, k) + } + } + // Preprocess settings allPreprocessors := make(map[string]SettingPreProcessor) allPostprocessors := make(map[string]SettingPostProcessor) diff --git a/service/basic/site.go b/service/basic/site.go index 865b1206..87e9cd5e 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -41,6 +41,8 @@ type SiteConfig struct { RegisterEnabled bool `json:"register_enabled,omitempty"` TosUrl string `json:"tos_url,omitempty"` PrivacyPolicyUrl string `json:"privacy_policy_url,omitempty"` + SSOEnabled bool `json:"sso_enabled,omitempty"` + SSODisplayName string `json:"sso_display_name,omitempty"` // Explorer section Icons string `json:"icons,omitempty"` @@ -89,6 +91,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { switch s.Section { case "login": legalDocs := settings.LegalDocuments(c) + sso := settings.SSO(c) return &SiteConfig{ LoginCaptcha: settings.LoginCaptchaEnabled(c), RegCaptcha: settings.RegCaptchaEnabled(c), @@ -97,6 +100,8 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { RegisterEnabled: settings.RegisterEnabled(c), PrivacyPolicyUrl: legalDocs.PrivacyPolicy, TosUrl: legalDocs.TermsOfService, + SSOEnabled: sso.Enabled && sso.Issuer != "" && sso.ClientID != "", + SSODisplayName: sso.DisplayName, }, nil case "explorer": explorerSettings := settings.ExplorerFrontendSettings(c) diff --git a/service/user/register.go b/service/user/register.go index 02735495..2117fa32 100644 --- a/service/user/register.go +++ b/service/user/register.go @@ -35,8 +35,13 @@ func (service *UserRegisterService) Register(c *gin.Context) serializer.Response settings := dep.SettingProvider() isEmailRequired := settings.EmailActivationEnabled(c) + email := strings.ToLower(service.UserName) + if err := CheckEmailAllowed(settings.EmailFilter(c), email); err != nil { + return serializer.Err(c, err) + } + args := &inventory.NewUserArgs{ - Email: strings.ToLower(service.UserName), + Email: email, PlainPassword: service.Password, Status: user.StatusActive, GroupID: settings.DefaultGroup(c), diff --git a/service/user/sso.go b/service/user/sso.go new file mode 100644 index 00000000..6784bd2b --- /dev/null +++ b/service/user/sso.go @@ -0,0 +1,584 @@ +package user + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "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/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/request" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" +) + +// Parameter contexts for SSO services. +type ( + SSOLoginParameterCtx struct{} + SSOCallbackParameterCtx struct{} + SSOExchangeParameterCtx struct{} +) + +const ( + ssoStateTTL = 600 // seconds the authorization state stays valid + ssoTicketTTL = 60 // seconds the one-time login ticket stays valid + ssoDiscoveryTTL = 3600 + ssoJWKSTTL = 900 +) + +// ssoState is the one-time server-side state stored for an in-flight SSO flow. +// Storing it in KV (single-use) prevents replay and binds the callback to the +// exact login attempt that generated it. +type ssoState struct { + Nonce string + Redirect string +} + +// SSOLoginService starts an inbound OIDC flow by redirecting the browser to +// the configured identity provider. +type SSOLoginService struct { + Redirect string `form:"redirect" json:"redirect"` +} + +// SSOLogin builds the authorization URL and redirects to the IdP. +func (service *SSOLoginService) SSOLogin(c *gin.Context) { + dep := dependency.FromContext(c) + settings := dep.SettingProvider() + sso := settings.SSO(c) + + redirect := sanitizeSSORedirect(service.Redirect) + if err := validateSSOConfig(sso); err != nil { + redirectToSigninWithError(c, settings, "sso_not_configured") + return + } + + discovery, err := ssoDiscovery(c, dep, sso) + if err != nil { + dep.Logger().Warning("SSO discovery failed: %s", err) + redirectToSigninWithError(c, settings, "sso_discovery_failed") + return + } + + state := ssoState{ + Nonce: util.RandStringRunesCrypto(32), + Redirect: redirect, + } + stateKey := ssoStateKey() + if err := dep.KV().Set(stateKey, state, ssoStateTTL); err != nil { + dep.Logger().Warning("Failed to persist SSO state: %s", err) + redirectToSigninWithError(c, settings, "sso_state_failed") + return + } + + authorize, err := url.Parse(discovery.AuthorizationEndpoint) + if err != nil { + redirectToSigninWithError(c, settings, "sso_discovery_failed") + return + } + q := authorize.Query() + q.Set("response_type", "code") + q.Set("client_id", sso.ClientID) + q.Set("redirect_uri", ssoCallbackURL(settings, c)) + q.Set("scope", sso.Scopes) + q.Set("state", stateKey) + q.Set("nonce", state.Nonce) + authorize.RawQuery = q.Encode() + + c.Redirect(http.StatusFound, authorize.String()) +} + +// SSOCallbackService completes the inbound OIDC flow. +type SSOCallbackService struct { + Code string `form:"code" json:"code"` + State string `form:"state" json:"state"` + Error string `form:"error" json:"error"` + ErrorDescription string `form:"error_description" json:"error_description"` +} + +// SSOCallback validates the response, resolves the user and redirects to the +// SPA with a one-time ticket. Tokens never appear in the URL. +func (service *SSOCallbackService) SSOCallback(c *gin.Context) { + dep := dependency.FromContext(c) + settings := dep.SettingProvider() + + fail := func(code string) { + redirectToSigninWithError(c, settings, code) + } + + if service.Error != "" { + dep.Logger().Info("SSO callback error from IdP: %s (%s)", service.Error, service.ErrorDescription) + fail("sso_denied") + return + } + + sso := settings.SSO(c) + if err := validateSSOConfig(sso); err != nil { + fail("sso_not_configured") + return + } + if service.State == "" || service.Code == "" { + fail("sso_invalid_response") + return + } + + // Single-use state: Get then Delete so a captured state cannot be replayed. + 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 + } + + discovery, err := ssoDiscovery(c, dep, sso) + if err != nil { + dep.Logger().Warning("SSO discovery failed: %s", err) + fail("sso_discovery_failed") + return + } + + validate := ssoURLValidator(c, sso) + httpClient := dep.RequestClient() + + tokens, err := exchangeOIDCCode(c, httpClient, discovery.TokenEndpoint, service.Code, ssoCallbackURL(settings, c), sso.ClientID, sso.ClientSecret, validate) + if err != nil { + dep.Logger().Warning("SSO token exchange failed: %s", err) + fail("sso_exchange_failed") + return + } + + claims, err := ssoVerifyIDToken(c, dep, sso, discovery, tokens.IDToken, state.Nonce) + if err != nil { + dep.Logger().Warning("SSO id_token validation failed: %s", err) + fail("sso_token_invalid") + return + } + + email := strings.ToLower(strings.TrimSpace(claims.Email)) + name := claims.Name + preferred := claims.PreferredUsername + + // Some providers omit email from the ID token; fall back to userinfo. + if email == "" && tokens.AccessToken != "" && discovery.UserinfoEndpoint != "" { + info, err := fetchOIDCUserInfo(c, httpClient, discovery.UserinfoEndpoint, tokens.AccessToken, validate) + if err != nil { + dep.Logger().Warning("SSO userinfo request failed: %s", err) + } else { + email = strings.ToLower(strings.TrimSpace(info.Email)) + if name == "" { + name = info.Name + } + if preferred == "" { + preferred = info.PreferredUsername + } + } + } + if email == "" { + fail("sso_no_email") + return + } + + targetUser, err := ssoResolveUser(c, dep, sso, email, name, preferred) + if err != nil { + dep.Logger().Info("SSO login rejected for %q: %s", email, err) + fail("sso_account_unavailable") + return + } + + // One-time ticket -> frontend exchanges it for a token pair via JSON API. + ticket := ssoTicketKey() + if err := dep.KV().Set(ticket, targetUser.ID, ssoTicketTTL); err != nil { + dep.Logger().Warning("Failed to persist SSO ticket: %s", err) + 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()) +} + +// SSOExchangeService trades a one-time callback ticket for a session token. +type SSOExchangeService struct { + Ticket string `form:"ticket" json:"ticket" binding:"required"` +} + +// SSOExchange validates the ticket and issues a builtin token pair. +func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) { + dep := dependency.FromContext(c) + + raw, ok := dep.KV().Get(service.Ticket) + if !ok { + return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid or expired SSO ticket", nil) + } + _ = dep.KV().Delete("", service.Ticket) + + uid, ok := raw.(int) + if !ok { + return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid SSO ticket", nil) + } + + u, err := dep.UserClient().GetByID(c, uid) + if err != nil { + return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err) + } + if err := checkUserStatus(u); err != nil { + return nil, err + } + + util.WithValue(c, inventory.UserCtx{}, u) + return IssueToken(c) +} + +// ssoResolveUser finds or provisions the local account for an SSO identity. +func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email, name, preferred string) (*ent.User, error) { + userClient := dep.UserClient() + + u, err := userClient.GetByEmail(c, email) + if err == nil { + if err := checkUserStatus(u); err != nil { + return nil, err + } + return u, nil + } + + if !sso.RegisterEnabled { + return nil, serializer.NewError(serializer.CodeNoPermissionErr, "SSO account provisioning is disabled", nil) + } + if err := CheckEmailAllowed(dep.SettingProvider().EmailFilter(c), email); err != nil { + return nil, err + } + + nick := preferred + if nick == "" { + nick = name + } + if nick == "" { + nick = strings.Split(email, "@")[0] + } + + newUser, err := userClient.Create(c, &inventory.NewUserArgs{ + Email: email, + Nick: nick, + Status: user.StatusActive, + GroupID: dep.SettingProvider().DefaultGroup(c), + }) + if err != nil { + return nil, fmt.Errorf("failed to provision SSO user: %w", err) + } + + return newUser, nil +} + +func checkUserStatus(u *ent.User) error { + switch u.Status { + case user.StatusSysBanned, user.StatusManualBanned: + return serializer.NewError(serializer.CodeUserBaned, "User is banned", nil) + case user.StatusInactive: + return serializer.NewError(serializer.CodeUserNotActivated, "User is not activated", nil) + } + return nil +} + +// CheckEmailAllowed enforces the sign-up email filter. Shared by the classic +// register endpoint and SSO account provisioning. +func CheckEmailAllowed(filter *setting.EmailFilter, email string) error { + local, domain, found := strings.Cut(email, "@") + if !found { + return serializer.NewError(serializer.CodeParamErr, "Invalid email", nil) + } + + if filter.DisableSubAddress && strings.Contains(local, "+") { + return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil) + } + + domain = strings.ToLower(domain) + listed := false + for _, entry := range filter.List { + if entry == domain || strings.HasSuffix(domain, "."+entry) { + listed = true + break + } + } + + switch filter.Mode { + case setting.EmailFilterWhitelist: + if !listed { + return serializer.NewError(serializer.CodeParamErr, "Email domain is not allowed", nil) + } + case setting.EmailFilterBlacklist: + if listed { + return serializer.NewError(serializer.CodeEmailProviderBaned, "Email domain is not allowed", nil) + } + } + + return nil +} + +func validateSSOConfig(sso *setting.SSO) error { + if !sso.Enabled || sso.Issuer == "" || sso.ClientID == "" { + return errors.New("sso not enabled or not configured") + } + return nil +} + +// ssoURLValidator wraps SSRF validation: the operator-trusted issuer host is +// allowlisted, endpoints on other internal hosts are rejected. +func ssoURLValidator(c *gin.Context, sso *setting.SSO) func(raw string) error { + allowed := []string{} + if u, err := url.Parse(sso.Issuer); err == nil && u.Hostname() != "" { + allowed = append(allowed, u.Hostname()) + } + return func(raw string) error { + return request.ValidateExternalURL(c, raw, request.SSRFOptions{AllowedHosts: allowed}) + } +} + +func ssoCallbackURL(settings setting.Provider, c *gin.Context) string { + return settings.SiteURL(c).ResolveReference(&url.URL{Path: "api/v4/session/sso/callback"}).String() +} + +func redirectToSigninWithError(c *gin.Context, settings setting.Provider, code string) { + dest := settings.SiteURL(c).ResolveReference(&url.URL{Path: "session"}) + q := dest.Query() + q.Set("sso_error", code) + dest.RawQuery = q.Encode() + c.Redirect(http.StatusFound, dest.String()) +} + +func ssoStateKey() string { return "sso_state_" + util.RandStringRunesCrypto(32) } +func ssoTicketKey() string { return "sso_ticket_" + util.RandStringRunesCrypto(32) } + +// sanitizeSSORedirect keeps only same-origin relative paths so a crafted +// login URL cannot bounce the browser to an external site after sign-in. +func sanitizeSSORedirect(raw string) string { + if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") { + return "" + } + return raw +} + +// ssoDiscovery returns the provider metadata, KV-cached by issuer. +func ssoDiscovery(c *gin.Context, dep dependency.Dep, sso *setting.SSO) (*auth.OIDCDiscovery, error) { + key := "sso_discovery_" + ssoIssuerHash(sso.Issuer) + if cached, ok := dep.KV().Get(key); ok { + if doc, ok := cached.(auth.OIDCDiscovery); ok { + return &doc, nil + } + } + + validate := ssoURLValidator(c, sso) + doc, err := fetchOIDCDiscovery(c, dep.RequestClient(), sso.Issuer, validate) + if err != nil { + return nil, err + } + + // Validate each endpoint as well; a compromised discovery doc must not + // become an SSRF proxy. + for _, endpoint := range []string{doc.TokenEndpoint, doc.JWKSURI, doc.UserinfoEndpoint} { + if endpoint == "" { + continue + } + if err := validate(endpoint); err != nil { + return nil, fmt.Errorf("unsafe OIDC endpoint %q: %w", endpoint, err) + } + } + + _ = dep.KV().Set(key, *doc, ssoDiscoveryTTL) + return doc, nil +} + +// ssoVerifyIDToken verifies the ID token, refetching JWKS once when the +// signing key is unknown (key rotation). +func ssoVerifyIDToken(c *gin.Context, dep dependency.Dep, sso *setting.SSO, doc *auth.OIDCDiscovery, idToken, nonce string) (*auth.OIDCIDTokenClaims, error) { + jwksKey := "sso_jwks_" + ssoIssuerHash(doc.JWKSURI) + validate := ssoURLValidator(c, sso) + + fetch := func() (*auth.JWKSet, error) { + jwks, err := fetchOIDCJWKS(c, dep.RequestClient(), doc.JWKSURI, validate) + if err != nil { + return nil, err + } + _ = dep.KV().Set(jwksKey, *jwks, ssoJWKSTTL) + return jwks, nil + } + + var jwks *auth.JWKSet + if cached, ok := dep.KV().Get(jwksKey); ok { + if j, ok := cached.(auth.JWKSet); ok { + jwks = &j + } + } + if jwks == nil { + var err error + jwks, err = fetch() + if err != nil { + return nil, err + } + } + + claims, err := auth.VerifyOIDCIDToken(idToken, doc.Issuer, sso.ClientID, nonce, jwks) + if errors.Is(err, auth.ErrOIDCKeyNotFound) { + if jwks, err = fetch(); err != nil { + return nil, err + } + claims, err = auth.VerifyOIDCIDToken(idToken, doc.Issuer, sso.ClientID, nonce, jwks) + } + + return claims, err +} + +func ssoIssuerHash(issuer string) string { + sum := sha256.Sum256([]byte(issuer)) + return hex.EncodeToString(sum[:8]) +} + +// fetchOIDCDiscovery retrieves the provider metadata document. +func fetchOIDCDiscovery(c *gin.Context, client request.Client, issuer string, validate func(raw string) error) (*auth.OIDCDiscovery, error) { + wellKnown := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration" + if err := validate(wellKnown); err != nil { + return nil, err + } + + resp, err := client. + Request(http.MethodGet, wellKnown, nil, + request.WithContext(c), + request.WithTimeout(10*time.Second), + ). + CheckHTTPResponse(http.StatusOK). + GetResponse() + if err != nil { + return nil, fmt.Errorf("%w: %v", auth.ErrOIDCDiscoveryFailed, err) + } + + var doc auth.OIDCDiscovery + if err := json.Unmarshal([]byte(resp), &doc); err != nil { + return nil, fmt.Errorf("malformed discovery document: %w", auth.ErrOIDCDiscoveryFailed) + } + if err := doc.Validate(); err != nil { + return nil, err + } + + return &doc, nil +} + +// fetchOIDCJWKS retrieves the provider's signing keys. +func fetchOIDCJWKS(c *gin.Context, client request.Client, jwksURI string, validate func(raw string) error) (*auth.JWKSet, error) { + if err := validate(jwksURI); err != nil { + return nil, err + } + + resp, err := client. + Request(http.MethodGet, jwksURI, nil, + request.WithContext(c), + request.WithTimeout(10*time.Second), + ). + CheckHTTPResponse(http.StatusOK). + GetResponse() + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + + var jwks auth.JWKSet + if err := json.Unmarshal([]byte(resp), &jwks); err != nil { + return nil, fmt.Errorf("malformed JWKS: %w", err) + } + if len(jwks.Keys) == 0 { + return nil, auth.ErrOIDCKeyNotFound + } + + return &jwks, nil +} + +// exchangeOIDCCode trades the authorization code for tokens at the token +// endpoint. Client secret is sent via HTTP basic auth (client_secret_basic). +func exchangeOIDCCode(c *gin.Context, client request.Client, tokenEndpoint, code, redirectURI, clientID, clientSecret string, validate func(raw string) error) (*auth.OIDCTokenResponse, error) { + if err := validate(tokenEndpoint); err != nil { + return nil, err + } + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + header := http.Header{ + "Content-Type": {"application/x-www-form-urlencoded"}, + "Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte(url.QueryEscape(clientID)+":"+url.QueryEscape(clientSecret)))}, + "Accept": {"application/json"}, + } + + resp, err := client. + Request(http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode()), + request.WithContext(c), + request.WithTimeout(15*time.Second), + request.WithHeader(header), + ). + CheckHTTPResponse(http.StatusOK). + GetResponse() + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + var tokens auth.OIDCTokenResponse + if err := json.Unmarshal([]byte(resp), &tokens); err != nil { + return nil, fmt.Errorf("malformed token response: %w", err) + } + if tokens.IDToken == "" { + return nil, fmt.Errorf("missing id_token: %w", auth.ErrOIDCInvalidToken) + } + + return &tokens, nil +} + +// fetchOIDCUserInfo retrieves claims from the userinfo endpoint, used when the +// ID token omits the email claim. +func fetchOIDCUserInfo(c *gin.Context, client request.Client, endpoint, accessToken string, validate func(raw string) error) (*auth.OIDCUserInfo, error) { + if err := validate(endpoint); err != nil { + return nil, err + } + + header := http.Header{ + "Authorization": {"Bearer " + accessToken}, + "Accept": {"application/json"}, + } + resp, err := client. + Request(http.MethodGet, endpoint, nil, + request.WithContext(c), + request.WithTimeout(10*time.Second), + request.WithHeader(header), + ). + CheckHTTPResponse(http.StatusOK). + GetResponse() + if err != nil { + return nil, fmt.Errorf("userinfo request failed: %w", err) + } + + var info auth.OIDCUserInfo + if err := json.Unmarshal([]byte(resp), &info); err != nil { + return nil, fmt.Errorf("malformed userinfo response: %w", err) + } + + return &info, nil +} diff --git a/service/user/sso_test.go b/service/user/sso_test.go new file mode 100644 index 00000000..2b0fbcdf --- /dev/null +++ b/service/user/sso_test.go @@ -0,0 +1,137 @@ +package user + +import ( + "testing" + + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestCheckEmailAllowed(t *testing.T) { + tests := []struct { + name string + filter *setting.EmailFilter + email string + wantErr bool + code int + }{ + { + name: "disabled filter allows any domain", + filter: &setting.EmailFilter{Mode: setting.EmailFilterDisabled}, + email: "a@anything.com", + }, + { + name: "whitelist allows listed domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterWhitelist, + List: []string{"example.com"}, + }, + email: "a@example.com", + }, + { + name: "whitelist allows subdomain of listed domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterWhitelist, + List: []string{"example.com"}, + }, + email: "a@mail.example.com", + }, + { + name: "whitelist rejects unlisted domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterWhitelist, + List: []string{"example.com"}, + }, + email: "a@other.com", + wantErr: true, + code: serializer.CodeParamErr, + }, + { + name: "whitelist does not suffix-match partial domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterWhitelist, + List: []string{"ample.com"}, + }, + email: "a@example.com", + wantErr: true, + code: serializer.CodeParamErr, + }, + { + name: "blacklist rejects listed domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterBlacklist, + List: []string{"spam.com"}, + }, + email: "a@spam.com", + wantErr: true, + code: serializer.CodeEmailProviderBaned, + }, + { + name: "blacklist rejects subdomain of listed domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterBlacklist, + List: []string{"spam.com"}, + }, + email: "a@mx.spam.com", + wantErr: true, + code: serializer.CodeEmailProviderBaned, + }, + { + name: "blacklist allows unlisted domain", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterBlacklist, + List: []string{"spam.com"}, + }, + email: "a@ok.com", + }, + { + name: "sub-address rejected when disabled", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterDisabled, + DisableSubAddress: true, + }, + email: "a+tag@example.com", + wantErr: true, + code: serializer.CodeParamErr, + }, + { + name: "sub-address allowed when enabled", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterDisabled, + DisableSubAddress: false, + }, + email: "a+tag@example.com", + }, + { + name: "invalid email rejected", + filter: &setting.EmailFilter{Mode: setting.EmailFilterDisabled}, + email: "not-an-email", + wantErr: true, + code: serializer.CodeParamErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckEmailAllowed(tt.filter, tt.email) + if tt.wantErr { + require.Error(t, err) + if appErr, ok := err.(serializer.AppError); ok { + require.Equal(t, tt.code, appErr.Code) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSanitizeSSORedirect(t *testing.T) { + require.Equal(t, "", sanitizeSSORedirect("")) + require.Equal(t, "", sanitizeSSORedirect("https://evil.com")) + require.Equal(t, "", sanitizeSSORedirect("//evil.com")) + require.Equal(t, "", sanitizeSSORedirect("evil.com/path")) + require.Equal(t, "/home", sanitizeSSORedirect("/home")) + require.Equal(t, "/share/s/abc", sanitizeSSORedirect("/share/s/abc")) +} From 591b49efa0ab5e6338cbc003369dbf6b03e8c0e2 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 18:47:10 +0200 Subject: [PATCH 2/2] docs(roadmap): mark B.3 SSO increment shipped Authored By: TDvorak --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 11d62b97..5dd7ceb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,8 @@ Order = user-visible value first; each ships with backend + UI + tests. - [ ] Share ACL — per-user/group grants on shares (#3517); paid share links deferred to VAS phase; default shares for new users (#3390) open 2. **Storage policy advanced** — multiple policies per group (group→policies join table), per-directory binding, load-balancer policy, file migration between policies (fixes #3518, #2961, #2262) 3. **SSO** — generic OIDC provider (PR #3472 base), Logto connector, multi-account switching, sign-up email filtering (fixes #3464, #3056, #3505) + - [x] PR #141 — inbound OIDC consumer (auth-code + nonce, JWKS-verified RS256 id_tokens, userinfo fallback, auto-provisioning, one-time ticket handoff, SSRF-validated endpoints, redacted secret); covers Keycloak/Authentik/Logto/generic IdPs; sign-up email domain filtering (whitelist/blacklist + sub-address block) enforced at registration and SSO provisioning; multi-account lands via existing session `upsert` (fixes #3464, #3056) + - [ ] QQ Connect (non-OIDC protocol, separate integration), account linking UI for existing local accounts, group/role claim mapping 4. **VAS/monetization-free** — credits + redemption codes as *free* features (gift codes for admin use), storage/membership plan definitions; skip payment processor integration initially — YAGNI until a real user asks (fixes #3231) 5. **System extensions** — activity/audit log surfaced in admin, site announcements, report-abuse queue (fixes #3480, #3479 IP whitelist)