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 <info@tdvorak.dev>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent f62488a354
commit aeed9904f9

@ -52,7 +52,22 @@
"signingOut": "Signing out...", "signingOut": "Signing out...",
"loggedOut": "You are signed out now.", "loggedOut": "You are signed out now.",
"clickToRefresh": "Click to refresh", "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": { "oauth": {
"authorize": "Authorize Application", "authorize": "Authorize Application",
@ -999,7 +1014,12 @@
"validDurationDays": "{{num}} days", "validDurationDays": "{{num}} days",
"reportTarget": "Report target", "reportTarget": "Report target",
"reportReason": "Reason", "reportReason": "Reason",
"reportReasonOptions": ["Copyright infringement", "Harmful content", "Spam", "Other"], "reportReasonOptions": [
"Copyright infringement",
"Harmful content",
"Spam",
"Other"
],
"reportDescription": "Additional description", "reportDescription": "Additional description",
"reportAbuseSuccess": "Report submitted." "reportAbuseSuccess": "Report submitted."
} }

@ -820,7 +820,13 @@
"ftsTikaExts": "Supported extensions", "ftsTikaExts": "Supported extensions",
"ftsTikaExtsDes": "Comma-separated list of file extensions. Tika will process files of these types.", "ftsTikaExtsDes": "Comma-separated list of file extensions. Tika will process files of these types.",
"ftsTikaMaxFileSize": "Maximum file size", "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</0>. The provider metadata is fetched from <1>.well-known/openid-configuration</1> 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}}</0>"
}, },
"giftCodes": { "giftCodes": {
"giftCodesSettings": "Gift Codes", "giftCodesSettings": "Gift Codes",
@ -1597,7 +1603,7 @@
"filterEmailProviderDisabled": "Disabled", "filterEmailProviderDisabled": "Disabled",
"filterEmailProviderWhitelist": "Whitelist", "filterEmailProviderWhitelist": "Whitelist",
"filterEmailProviderBlacklist": "Blacklist", "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", "filterEmailProviderRule": "Email domain filter rules",
"filterEmailProviderRuleDes": "Separate multiple fields with a semi-colon comma.", "filterEmailProviderRuleDes": "Separate multiple fields with a semi-colon comma.",
"qqConnect": "QQ Connect", "qqConnect": "QQ Connect",

@ -52,7 +52,22 @@
"signingOut": "正在退出登录...", "signingOut": "正在退出登录...",
"loggedOut": "您已退出登录", "loggedOut": "您已退出登录",
"clickToRefresh": "点击刷新验证码", "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": { "oauth": {
"authorize": "授权应用", "authorize": "授权应用",
@ -999,7 +1014,12 @@
"validDurationDays": "{{num}} 天", "validDurationDays": "{{num}} 天",
"reportTarget": "举报对象", "reportTarget": "举报对象",
"reportReason": "原因", "reportReason": "原因",
"reportReasonOptions": ["侵权", "有害内容", "垃圾信息", "其他"], "reportReasonOptions": [
"侵权",
"有害内容",
"垃圾信息",
"其他"
],
"reportDescription": "补充说明", "reportDescription": "补充说明",
"reportAbuseSuccess": "举报已提交" "reportAbuseSuccess": "举报已提交"
} }

@ -820,7 +820,13 @@
"ftsTikaExts": "支持的扩展名", "ftsTikaExts": "支持的扩展名",
"ftsTikaExtsDes": "逗号分隔的文件扩展名列表,Tika 将处理这些类型的文件。", "ftsTikaExtsDes": "逗号分隔的文件扩展名列表,Tika 将处理这些类型的文件。",
"ftsTikaMaxFileSize": "最大文件大小", "ftsTikaMaxFileSize": "最大文件大小",
"ftsTikaMaxFileSizeDes": "发送至 Tika 进行内容提取的文件最大大小。" "ftsTikaMaxFileSizeDes": "发送至 Tika 进行内容提取的文件最大大小。",
"ssoIssuer": "Issuer 地址",
"ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master</0>。将从该地址下的 <1>.well-known/openid-configuration</1> 获取提供方元数据。",
"ssoRegisterEnabled": "允许自动注册",
"ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。",
"ssoCallbackUrl": "回调地址",
"ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}</0>"
}, },
"giftCodes": { "giftCodes": {
"giftCodesSettings": "礼品码", "giftCodesSettings": "礼品码",
@ -1596,7 +1602,7 @@
"filterEmailProviderDisabled": "不启用", "filterEmailProviderDisabled": "不启用",
"filterEmailProviderWhitelist": "白名单", "filterEmailProviderWhitelist": "白名单",
"filterEmailProviderBlacklist": "黑名单", "filterEmailProviderBlacklist": "黑名单",
"filterEmailProviderDes": "只允许使用特定的邮箱注册站点,第三方 SSO 登录不受此限制。", "filterEmailProviderDes": "限制新注册账号可使用的邮箱提供商,包括通过 SSO 自动创建的账号。",
"filterEmailProviderRule": "邮箱域过滤规则", "filterEmailProviderRule": "邮箱域过滤规则",
"filterEmailProviderRuleDes": "多个域请使用半角逗号 , 隔开。", "filterEmailProviderRuleDes": "多个域请使用半角逗号 , 隔开。",
"qqConnect": "QQ 互联", "qqConnect": "QQ 互联",

@ -189,6 +189,24 @@ export function sendLogin(req: PasswordLoginRequest): ThunkResponse<LoginRespons
}; };
} }
export function sendSSOExchange(ticket: string): ThunkResponse<LoginResponse> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/session/sso/exchange",
{
data: { ticket },
method: "POST",
},
{
...defaultOpts,
noCredential: true,
},
),
);
};
}
export function send2FALogin(req: TwoFALoginRequest): ThunkResponse<LoginResponse> { export function send2FALogin(req: TwoFALoginRequest): ThunkResponse<LoginResponse> {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
return await dispatch( return await dispatch(

@ -28,6 +28,8 @@ export interface SiteConfig {
captcha_cap_secret_key?: string; captcha_cap_secret_key?: string;
captcha_cap_asset_server?: string; captcha_cap_asset_server?: string;
register_enabled?: boolean; register_enabled?: boolean;
sso_enabled?: boolean;
sso_display_name?: string;
logo?: string; logo?: string;
logo_light?: string; logo_light?: string;
tos_url?: string; tos_url?: string;

@ -191,6 +191,17 @@ const Settings = () => {
"avatar_size", "avatar_size",
"avatar_size_l", "avatar_size_l",
"gravatar_server", "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",
]} ]}
> >
<UserSession /> <UserSession />

@ -1,9 +1,13 @@
import { ExpandMoreRounded } from "@mui/icons-material"; 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 MuiAccordionSummary, { AccordionSummaryProps } from "@mui/material/AccordionSummary";
import { useCallback, useState } from "react"; import { useContext, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { StyledCheckbox } from "../../../Common/StyledComponents.tsx"; 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) => <MuiAccordionSummary {...props} />)( export const AccordionSummary = styled((props: AccordionSummaryProps) => <MuiAccordionSummary {...props} />)(
({ theme }) => ({ ({ theme }) => ({
@ -26,38 +30,120 @@ export const StyledAccordion = styled(Accordion)(({ theme }) => ({
}, },
})); }));
export interface SettingSectionProps {}
const SSOSettings = () => { const SSOSettings = () => {
const [open, setOpen] = useState(false);
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
const onClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => { const { setSettings, values } = useContext(SettingContext);
event.stopPropagation();
setOpen(true); 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 ( return (
<> <StyledAccordion disableGutters>
<div onClick={onClick}>
<StyledAccordion expanded={false} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel control={<StyledCheckbox size={"small"} checked={false} />} label={t("vas.qqConnect")} />
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}></AccordionDetails>
</StyledAccordion>
<StyledAccordion expanded={false} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel control={<StyledCheckbox size={"small"} checked={false} />} label={t("settings.logto")} />
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}></AccordionDetails>
</StyledAccordion>
<StyledAccordion expanded={false} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}> <AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel control={<StyledCheckbox size={"small"} checked={false} />} label={t("settings.oidc")} /> <FormControlLabel
control={
<Switch
size="small"
checked={enabled}
onChange={(e) =>
setSettings({
sso_enabled: e.target.checked ? "1" : "0",
})
}
onClick={(e) => e.stopPropagation()}
/>
}
label={t("settings.oidc")}
/>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{ display: "block" }}></AccordionDetails> <AccordionDetails sx={{ display: "block" }}>
<SettingSectionContent>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.displayName")}
value={values.sso_display_name}
onChange={(e) => setSettings({ sso_display_name: e.target.value })}
required
/>
<NoMarginHelperText>{t("settings.displayNameDes")}</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.ssoIssuer")}
value={values.sso_issuer}
onChange={(e) => setSettings({ sso_issuer: e.target.value })}
placeholder="https://keycloak.example.com/realms/master"
required={enabled}
/>
<NoMarginHelperText>
<Trans i18nKey="settings.ssoIssuerDes" ns="dashboard" components={[<Code key="0" />, <Code key="1" />]} />
</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.clientID")}
value={values.sso_client_id}
onChange={(e) => setSettings({ sso_client_id: e.target.value })}
required={enabled}
/>
<NoMarginHelperText>{t("settings.clientIDDes")}</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.clientSecret")}
value={values.sso_client_secret ?? ""}
onChange={(e) => setSettings({ sso_client_secret: e.target.value })}
type="password"
placeholder={t("oauth.secretRedactedPlaceholder")}
/>
<NoMarginHelperText>{t("oauth.clientSecretDesExisting")}</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.scope")}
value={values.sso_scopes}
onChange={(e) => setSettings({ sso_scopes: e.target.value })}
placeholder="groups, roles"
/>
<NoMarginHelperText>
<Trans i18nKey="settings.scopeDes" ns="dashboard" components={[<Code key="0" />]} />
</NoMarginHelperText>
</FormControl>
{callbackURL && (
<FormControl fullWidth>
<DenseFilledTextField
label={t("settings.ssoCallbackUrl")}
value={callbackURL}
slotProps={{ input: { readOnly: true } }}
/>
<NoMarginHelperText>
<Trans i18nKey="settings.ssoCallbackUrlDes" ns="dashboard" values={{ url: callbackURL }} components={[<Code key="0" />]} />
</NoMarginHelperText>
</FormControl>
)}
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.sso_register_enabled)}
onChange={(e) =>
setSettings({
sso_register_enabled: e.target.checked ? "1" : "0",
})
}
/>
}
label={<Typography variant="body2">{t("settings.ssoRegisterEnabled")}</Typography>}
/>
<NoMarginHelperText>{t("settings.ssoRegisterEnabledDes")}</NoMarginHelperText>
</FormControl>
</SettingSectionContent>
</AccordionDetails>
</StyledAccordion> </StyledAccordion>
</div>
</>
); );
}; };

@ -141,10 +141,17 @@ const UserSession = () => {
</SettingForm> </SettingForm>
<SettingForm title={t("vas.filterEmailProvider")} lgWidth={5}> <SettingForm title={t("vas.filterEmailProvider")} lgWidth={5}>
<FormControl> <FormControl>
<DenseSelect value={0}> <DenseSelect
value={values.email_filter_mode ?? "0"}
onChange={(e) =>
setSettings({
email_filter_mode: e.target.value as string,
})
}
>
{["filterEmailProviderDisabled", "filterEmailProviderWhitelist", "filterEmailProviderBlacklist"].map( {["filterEmailProviderDisabled", "filterEmailProviderWhitelist", "filterEmailProviderBlacklist"].map(
(v, i) => ( (v, i) => (
<SquareMenuItem value={i.toString()}> <SquareMenuItem key={v} value={i.toString()}>
<ListItemText <ListItemText
slotProps={{ slotProps={{
primary: { variant: "body2" }, primary: { variant: "body2" },
@ -159,10 +166,37 @@ const UserSession = () => {
<NoMarginHelperText>{t("vas.filterEmailProviderDes")}</NoMarginHelperText> <NoMarginHelperText>{t("vas.filterEmailProviderDes")}</NoMarginHelperText>
</FormControl> </FormControl>
</SettingForm> </SettingForm>
{(values.email_filter_mode ?? "0") !== "0" && (
<SettingForm title={t("vas.filterEmailProviderRule")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.email_filter_list}
onChange={(e) =>
setSettings({
email_filter_list: e.target.value,
})
}
multiline
minRows={3}
placeholder={"example.com\nmail.example.org"}
/>
<NoMarginHelperText>{t("vas.filterEmailProviderRuleDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
<SettingForm lgWidth={5}> <SettingForm lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<FormControlLabel <FormControlLabel
control={<Switch checked={false} />} control={
<Switch
checked={isTrueVal(values.email_disable_subaddress)}
onChange={(e) =>
setSettings({
email_disable_subaddress: e.target.checked ? "1" : "0",
})
}
/>
}
label={ label={
<> <>
{t("vas.disableSubAddressEmail")} {t("vas.disableSubAddressEmail")}

@ -8,6 +8,7 @@ import { useQuery } from "../../../../util";
import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx"; import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField.tsx";
import MailOutlined from "../../../Icons/MailOutlined.tsx"; import MailOutlined from "../../../Icons/MailOutlined.tsx";
import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx"; import PasskeyLoginButton from "../Signin/PasskeyLoginButton.tsx";
import SSOLoginButton from "../Signin/SSOLoginButton.tsx";
import { Control } from "../Signin/SignIn.tsx"; import { Control } from "../Signin/SignIn.tsx";
export const LegalLinks = () => { export const LegalLinks = () => {
@ -52,11 +53,11 @@ interface PhaseCollectEmailProps {
const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: PhaseCollectEmailProps) => { const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: PhaseCollectEmailProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const query = useQuery(); 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 tos = useAppSelector((state) => state.siteConfig.login.config.tos_url);
const privacyPolicy = useAppSelector((state) => state.siteConfig.login.config.privacy_policy_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(() => { useEffect(() => {
if (!!query.get("email")) { if (!!query.get("email")) {
@ -97,7 +98,10 @@ const PhaseCollectEmail = ({ email, setEmail, control, onOAuthPasskeyLogin }: Ph
{showFooter && ( {showFooter && (
<> <>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Stack spacing={1}>{authn && <PasskeyLoginButton autoComplete onLoginSuccess={onOAuthPasskeyLogin} />}</Stack> <Stack spacing={1}>
{authn && <PasskeyLoginButton autoComplete onLoginSuccess={onOAuthPasskeyLogin} />}
<SSOLoginButton />
</Stack>
<LegalLinks /> <LegalLinks />
</> </>
)} )}

@ -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 (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
pt: 7,
pb: 9,
}}
>
{missingTicket ? (
<>
<DismissCircleFilled fontSize="large" color="error" />
<Typography variant="body2" sx={{ color: (theme) => theme.palette.error.main, mt: 2 }}>
{t("login.ssoFailed")}
</Typography>
</>
) : (
<CircularProgress />
)}
</Box>
);
};
export default SSOCallback;

@ -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 (
<Button fullWidth variant="outlined" startIcon={<Enter />} onClick={startLogin} {...props}>
{t("login.signInWith", { name: sso_display_name || "SSO" })}
</Button>
);
}

@ -303,6 +303,15 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
// Initial load for OAuth or regular login // Initial load for OAuth or regular login
useEffect(() => { 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 () => { const init = async () => {
if (isOAuthFlow) { if (isOAuthFlow) {
const registration = await loadAppRegistration(); const registration = await loadAppRegistration();

@ -9,6 +9,7 @@ import Reset from "../component/Pages/Login/Reset.tsx";
import SessionIntro from "../component/Pages/Login/SessionIntro.tsx"; import SessionIntro from "../component/Pages/Login/SessionIntro.tsx";
import DesktopCallback from "../component/Pages/Login/Signin/DesktopCallback.tsx"; import DesktopCallback from "../component/Pages/Login/Signin/DesktopCallback.tsx";
import MobileCallback from "../component/Pages/Login/Signin/MobileCallback.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 SignIn from "../component/Pages/Login/Signin/SignIn.tsx";
import SignUp from "../component/Pages/Login/Signup.tsx"; import SignUp from "../component/Pages/Login/Signup.tsx";
import NoMatch from "../component/Pages/NoMatch.tsx"; import NoMatch from "../component/Pages/NoMatch.tsx";
@ -68,6 +69,10 @@ export const router = createBrowserRouter([
path: "ios", path: "ios",
element: <MobileCallback />, element: <MobileCallback />,
}, },
{
path: "sso",
element: <SSOCallback />,
},
], ],
}, },
{ {

@ -551,6 +551,16 @@ var DefaultSettings = map[string]string{
"cron_trash_bin_collect": "@every 33m", "cron_trash_bin_collect": "@every 33m",
"cron_oauth_cred_refresh": "@every 230h", "cron_oauth_cred_refresh": "@every 230h",
"authn_enabled": "1", "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_type": "normal",
"captcha_height": "60", "captcha_height": "60",
"captcha_width": "240", "captcha_width": "240",
@ -710,6 +720,7 @@ var RedactedSettings = map[string]struct{}{
"encrypt_master_key": {}, "encrypt_master_key": {},
"secret_key": {}, "secret_key": {},
"oidc_signing_private_key": {}, "oidc_signing_private_key": {},
"sso_client_secret": {},
} }
func init() { func init() {

@ -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
}

@ -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)
}

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -241,6 +242,10 @@ type (
// ExposeUserEmail returns true if user email should be exposed to other // ExposeUserEmail returns true if user email should be exposed to other
// signed-in users in redacted responses. // signed-in users in redacted responses.
ExposeUserEmail(ctx context.Context) bool 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{} UseFirstSiteUrlCtxKey = struct{}
) )
@ -869,6 +874,63 @@ func (s *settingProvider) RegisterEnabled(ctx context.Context) bool {
return s.getBoolean(ctx, "register_enabled", false) 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 { func (s *settingProvider) ExposeUserEmail(ctx context.Context) bool {
return s.getBoolean(ctx, "expose_user_email", true) return s.getBoolean(ctx, "expose_user_email", true)
} }

@ -72,6 +72,32 @@ type TokenAuth struct {
RefreshTokenTTL time.Duration 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 { type DBFS struct {
UseCursorPagination bool UseCursorPagination bool
MaxPageSize int MaxPageSize int

@ -355,6 +355,32 @@ func UserPrepareLogin(c *gin.Context) {
c.JSON(200, serializer.Response{Data: res}) 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 // UserSearch Search user by keyword
func UserSearch(c *gin.Context) { func UserSearch(c *gin.Context) {
service := ParametersFromContext[*user.SearchUserService](c, user.SearchUserParamCtx{}) service := ParametersFromContext[*user.SearchUserService](c, user.SearchUserParamCtx{})

@ -319,6 +319,23 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.UserPrepareLogin, 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 := session.Group("oauth")
{ {
oauthRouter.GET("app/:app_id", oauthRouter.GET("app/:app_id",

@ -279,6 +279,14 @@ func (s *SetSettingService) SetSetting(c *gin.Context) (map[string]string, error
kv := dep.KV() kv := dep.KV()
settingClient := dep.SettingClient() 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 // Preprocess settings
allPreprocessors := make(map[string]SettingPreProcessor) allPreprocessors := make(map[string]SettingPreProcessor)
allPostprocessors := make(map[string]SettingPostProcessor) allPostprocessors := make(map[string]SettingPostProcessor)

@ -41,6 +41,8 @@ type SiteConfig struct {
RegisterEnabled bool `json:"register_enabled,omitempty"` RegisterEnabled bool `json:"register_enabled,omitempty"`
TosUrl string `json:"tos_url,omitempty"` TosUrl string `json:"tos_url,omitempty"`
PrivacyPolicyUrl string `json:"privacy_policy_url,omitempty"` PrivacyPolicyUrl string `json:"privacy_policy_url,omitempty"`
SSOEnabled bool `json:"sso_enabled,omitempty"`
SSODisplayName string `json:"sso_display_name,omitempty"`
// Explorer section // Explorer section
Icons string `json:"icons,omitempty"` Icons string `json:"icons,omitempty"`
@ -89,6 +91,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
switch s.Section { switch s.Section {
case "login": case "login":
legalDocs := settings.LegalDocuments(c) legalDocs := settings.LegalDocuments(c)
sso := settings.SSO(c)
return &SiteConfig{ return &SiteConfig{
LoginCaptcha: settings.LoginCaptchaEnabled(c), LoginCaptcha: settings.LoginCaptchaEnabled(c),
RegCaptcha: settings.RegCaptchaEnabled(c), RegCaptcha: settings.RegCaptchaEnabled(c),
@ -97,6 +100,8 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
RegisterEnabled: settings.RegisterEnabled(c), RegisterEnabled: settings.RegisterEnabled(c),
PrivacyPolicyUrl: legalDocs.PrivacyPolicy, PrivacyPolicyUrl: legalDocs.PrivacyPolicy,
TosUrl: legalDocs.TermsOfService, TosUrl: legalDocs.TermsOfService,
SSOEnabled: sso.Enabled && sso.Issuer != "" && sso.ClientID != "",
SSODisplayName: sso.DisplayName,
}, nil }, nil
case "explorer": case "explorer":
explorerSettings := settings.ExplorerFrontendSettings(c) explorerSettings := settings.ExplorerFrontendSettings(c)

@ -35,8 +35,13 @@ func (service *UserRegisterService) Register(c *gin.Context) serializer.Response
settings := dep.SettingProvider() settings := dep.SettingProvider()
isEmailRequired := settings.EmailActivationEnabled(c) 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{ args := &inventory.NewUserArgs{
Email: strings.ToLower(service.UserName), Email: email,
PlainPassword: service.Password, PlainPassword: service.Password,
Status: user.StatusActive, Status: user.StatusActive,
GroupID: settings.DefaultGroup(c), GroupID: settings.DefaultGroup(c),

@ -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
}

@ -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"))
}
Loading…
Cancel
Save