diff --git a/docs/11-auth-extensibility-and-private-sso.md b/docs/11-auth-extensibility-and-private-sso.md index 4158f8f8..dcf66029 100644 --- a/docs/11-auth-extensibility-and-private-sso.md +++ b/docs/11-auth-extensibility-and-private-sso.md @@ -88,6 +88,12 @@ public interface DirectAuthProvider { - `private-sso-cookie`:读取共享 Cookie 并向 SSO 校验 - 后续如果需要,也可以补“用户名密码直连认证 provider”扩展点 +为减少私有 fork 的前端硬编码,扩展 provider 可额外声明展示名称: + +- `DirectAuthProvider.displayName()` 默认回退为 `providerCode()` +- `PassiveSessionAuthenticator.displayName()` 默认回退为 `providerCode()` +- `GET /api/v1/auth/methods` 会返回该展示名称,供登录页直接渲染 + ## 4. 本轮已落地内容 - 新增 `PassiveSessionAuthenticator` SPI diff --git a/docs/12-private-sso-integration-playbook.md b/docs/12-private-sso-integration-playbook.md index c7fc7dda..786317fb 100644 --- a/docs/12-private-sso-integration-playbook.md +++ b/docs/12-private-sso-integration-playbook.md @@ -167,6 +167,7 @@ private-sso - `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值 - 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code +- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射 #### 步骤 2:封装 SSO 客户端 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 2ec06f5f..3e1f53c8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -81,7 +81,7 @@ public class AuthMethodCatalog { "direct-" + provider.providerCode(), "DIRECT_PASSWORD", provider.providerCode(), - provider.providerCode(), + provider.displayName(), "/api/v1/auth/direct/login" ))); } @@ -93,7 +93,7 @@ public class AuthMethodCatalog { "bootstrap-" + provider.providerCode(), "SESSION_BOOTSTRAP", provider.providerCode(), - provider.providerCode(), + provider.displayName(), "/api/v1/auth/session/bootstrap" ))); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java new file mode 100644 index 00000000..35ca8d75 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.config.DirectAuthProperties; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; + +class AuthMethodCatalogTest { + + @Test + void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + DirectAuthProperties directAuthProperties = new DirectAuthProperties(); + directAuthProperties.setEnabled(true); + AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); + bootstrapProperties.setEnabled(true); + + DirectAuthProvider directProvider = new DirectAuthProvider() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public String displayName() { + return "Enterprise Password"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + throw new UnsupportedOperationException("not used in catalog test"); + } + }; + + PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public String displayName() { + return "Enterprise SSO"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.empty(); + } + }; + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + directAuthProperties, + bootstrapProperties, + List.of(directProvider), + List.of(bootstrapProvider) + ); + + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id() + ":" + method.displayName()) + .contains( + "local-password:Local Account", + "direct-private-sso:Enterprise Password", + "bootstrap-private-sso:Enterprise SSO" + ); + } + + @Test + void listMethodsShouldFallBackToProviderCodeWhenDisplayNameIsNotOverridden() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + DirectAuthProperties directAuthProperties = new DirectAuthProperties(); + directAuthProperties.setEnabled(true); + AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); + bootstrapProperties.setEnabled(true); + + DirectAuthProvider directProvider = new DirectAuthProvider() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + return mock(PlatformPrincipal.class); + } + }; + + PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.empty(); + } + }; + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + directAuthProperties, + bootstrapProperties, + List.of(directProvider), + List.of(bootstrapProvider) + ); + + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id() + ":" + method.displayName()) + .contains( + "direct-private-sso:private-sso", + "bootstrap-private-sso:private-sso" + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java index 72be3565..ef00284b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java @@ -12,5 +12,9 @@ public interface PassiveSessionAuthenticator { String providerCode(); + default String displayName() { + return providerCode(); + } + Optional authenticate(HttpServletRequest request); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java index 7f62e53f..eb9b4331 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java @@ -9,5 +9,9 @@ public interface DirectAuthProvider { String providerCode(); + default String displayName() { + return providerCode(); + } + PlatformPrincipal authenticate(DirectAuthRequest request); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java index 45f4d4ff..181b85a3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java @@ -18,6 +18,11 @@ public class LocalDirectAuthProvider implements DirectAuthProvider { return "local"; } + @Override + public String displayName() { + return "Local Account"; + } + @Override public PlatformPrincipal authenticate(DirectAuthRequest request) { return localAuthService.login(request.username(), request.password()); diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index d1c3cbeb..a7dad6d1 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -1,8 +1,6 @@ -import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { authApi } from '@/api/client' import { Button } from '@/shared/ui/button' -import type { AuthMethod } from '@/api/types' +import { useAuthMethods } from './use-auth-methods' interface LoginButtonProps { returnTo?: string @@ -10,10 +8,7 @@ interface LoginButtonProps { export function LoginButton({ returnTo }: LoginButtonProps) { const { t } = useTranslation() - const { data, isLoading } = useQuery({ - queryKey: ['auth', 'methods', returnTo ?? ''], - queryFn: () => authApi.getMethods(returnTo), - }) + const { data, isLoading } = useAuthMethods(returnTo) const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT') diff --git a/web/src/features/auth/session-bootstrap-entry.tsx b/web/src/features/auth/session-bootstrap-entry.tsx index 98ab85af..eb2651e2 100644 --- a/web/src/features/auth/session-bootstrap-entry.tsx +++ b/web/src/features/auth/session-bootstrap-entry.tsx @@ -6,13 +6,15 @@ import { useSessionBootstrap } from './use-session-bootstrap' interface SessionBootstrapEntryProps { onAuthenticated: () => Promise + methodDisplayName?: string } -export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntryProps) { +export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: SessionBootstrapEntryProps) { const { t } = useTranslation() const config = getSessionBootstrapRuntimeConfig() const bootstrapMutation = useSessionBootstrap() const attemptedRef = useRef(false) + const providerName = methodDisplayName || t('login.enterpriseSsoTitle') useEffect(() => { if (!config.enabled || !config.provider || !config.auto || attemptedRef.current) { @@ -43,10 +45,12 @@ export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntry

- {t('login.enterpriseSsoTitle')} + {providerName}

- {config.auto ? t('login.enterpriseSsoAutoHint') : t('login.enterpriseSsoHint')} + {config.auto + ? t('login.enterpriseSsoAutoHint', { name: providerName }) + : t('login.enterpriseSsoHint', { name: providerName })}

@@ -66,7 +70,9 @@ export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntry }) }} > - {bootstrapMutation.isPending ? t('login.enterpriseSsoSubmitting') : t('login.enterpriseSsoAction')} + {bootstrapMutation.isPending + ? t('login.enterpriseSsoSubmitting', { name: providerName }) + : t('login.enterpriseSsoAction', { name: providerName })} {manualError ? ( diff --git a/web/src/features/auth/use-auth-methods.ts b/web/src/features/auth/use-auth-methods.ts new file mode 100644 index 00000000..b05c82ce --- /dev/null +++ b/web/src/features/auth/use-auth-methods.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { AuthMethod } from '@/api/types' + +export function useAuthMethods(returnTo?: string) { + return useQuery({ + queryKey: ['auth', 'methods', returnTo ?? ''], + queryFn: () => authApi.getMethods(returnTo), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 81ab1452..2cf053fb 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -106,7 +106,7 @@ "title": "Login to SkillHub", "subtitle": "Choose a method to continue", "tabPassword": "Password", - "tabOAuth": "GitHub", + "tabOAuth": "OAuth", "username": "Username", "password": "Password", "usernamePlaceholder": "Enter username", @@ -116,12 +116,12 @@ "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", - "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to the configured direct authentication provider instead of the fixed local account endpoint.", + "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.", "enterpriseSsoTitle": "Enterprise SSO", - "enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has an enterprise SSO session, you can try establishing a SkillHub session directly.", - "enterpriseSsoAutoHint": "This deployment has automatic enterprise SSO probing enabled. If it does not succeed, you can continue with the standard login methods.", - "enterpriseSsoAction": "Try Enterprise SSO", - "enterpriseSsoSubmitting": "Trying enterprise SSO...", + "enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.", + "enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.", + "enterpriseSsoAction": "Try {{name}}", + "enterpriseSsoSubmitting": "Trying {{name}}...", "agreementPrefix": "By logging in, you agree to our", "terms": "Terms of Service", "and": "and", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 8ea8b579..88b8fef0 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -106,7 +106,7 @@ "title": "登录 SkillHub", "subtitle": "选择一个方式登录以继续", "tabPassword": "账号密码", - "tabOAuth": "GitHub", + "tabOAuth": "OAuth", "username": "用户名", "password": "密码", "usernamePlaceholder": "输入用户名", @@ -116,12 +116,12 @@ "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", - "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到配置的直连认证提供方,而不是固定使用本地账号接口。", + "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。", "enterpriseSsoTitle": "企业单点登录", - "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在企业 SSO 会话,可直接尝试建立 SkillHub 登录态。", - "enterpriseSsoAutoHint": "当前部署已启用自动企业单点登录探测。若未成功,你仍可继续使用现有登录方式。", - "enterpriseSsoAction": "尝试企业 SSO 登录", - "enterpriseSsoSubmitting": "正在尝试企业 SSO 登录...", + "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。", + "enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。", + "enterpriseSsoAction": "尝试 {{name}} 登录", + "enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...", "agreementPrefix": "登录即表示你同意我们的", "terms": "服务条款", "and": "和", diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 4ca5b18d..8ddd4ba4 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { getDirectAuthRuntimeConfig } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry' +import { useAuthMethods } from '@/features/auth/use-auth-methods' import { usePasswordLogin } from '@/features/auth/use-password-login' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' @@ -18,8 +19,14 @@ export function LoginPage() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' + const { data: authMethods } = useAuthMethods(search.returnTo) const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + const directMethod = directAuthConfig.provider + ? authMethods?.find((method) => + method.methodType === 'DIRECT_PASSWORD' && method.provider === directAuthConfig.provider) + : undefined + const bootstrapMethod = authMethods?.find((method) => method.methodType === 'SESSION_BOOTSTRAP') async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -47,6 +54,7 @@ export function LoginPage() {
navigate({ to: returnTo })} /> @@ -60,7 +68,9 @@ export function LoginPage() {
{directAuthConfig.enabled ? (

- {t('login.passwordCompatHint')} + {t('login.passwordCompatHint', { + name: directMethod?.displayName ?? directAuthConfig.provider, + })}

) : null}