feat(auth): improve extensible login method metadata

This commit is contained in:
yun-zhi-ztl 2026-03-14 18:52:35 +08:00
parent 3f1985da46
commit 188f6108d1
13 changed files with 192 additions and 26 deletions

View file

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

View file

@ -167,6 +167,7 @@ private-sso
- `DirectAuthProvider.providerCode()``PassiveSessionAuthenticator.providerCode()` 返回同一个值
- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code
- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射
#### 步骤 2封装 SSO 客户端

View file

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

View file

@ -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<PlatformPrincipal> 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<PlatformPrincipal> 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"
);
}
}

View file

@ -12,5 +12,9 @@ public interface PassiveSessionAuthenticator {
String providerCode();
default String displayName() {
return providerCode();
}
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
}

View file

@ -9,5 +9,9 @@ public interface DirectAuthProvider {
String providerCode();
default String displayName() {
return providerCode();
}
PlatformPrincipal authenticate(DirectAuthRequest request);
}

View file

@ -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());

View file

@ -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<AuthMethod[]>({
queryKey: ['auth', 'methods', returnTo ?? ''],
queryFn: () => authApi.getMethods(returnTo),
})
const { data, isLoading } = useAuthMethods(returnTo)
const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT')

View file

@ -6,13 +6,15 @@ import { useSessionBootstrap } from './use-session-bootstrap'
interface SessionBootstrapEntryProps {
onAuthenticated: () => Promise<void>
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
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4 space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{t('login.enterpriseSsoTitle')}
{providerName}
</p>
<p className="text-sm text-muted-foreground">
{config.auto ? t('login.enterpriseSsoAutoHint') : t('login.enterpriseSsoHint')}
{config.auto
? t('login.enterpriseSsoAutoHint', { name: providerName })
: t('login.enterpriseSsoHint', { name: providerName })}
</p>
</div>
@ -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 })}
</Button>
{manualError ? (

View file

@ -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<AuthMethod[]>({
queryKey: ['auth', 'methods', returnTo ?? ''],
queryFn: () => authApi.getMethods(returnTo),
})
}

View file

@ -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",

View file

@ -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": "和",

View file

@ -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<HTMLFormElement>) {
event.preventDefault()
@ -47,6 +54,7 @@ export function LoginPage() {
<div className="glass-strong p-8 rounded-2xl">
<div className="space-y-6">
<SessionBootstrapEntry
methodDisplayName={bootstrapMethod?.displayName}
onAuthenticated={() => navigate({ to: returnTo })}
/>
@ -60,7 +68,9 @@ export function LoginPage() {
<form className="space-y-4" onSubmit={handleSubmit}>
{directAuthConfig.enabled ? (
<p className="text-sm text-muted-foreground">
{t('login.passwordCompatHint')}
{t('login.passwordCompatHint', {
name: directMethod?.displayName ?? directAuthConfig.provider,
})}
</p>
) : null}
<div className="space-y-2">