mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(auth): improve extensible login method metadata
This commit is contained in:
parent
3f1985da46
commit
188f6108d1
13 changed files with 192 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ private-sso
|
|||
|
||||
- `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值
|
||||
- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code
|
||||
- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射
|
||||
|
||||
#### 步骤 2:封装 SSO 客户端
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,5 +12,9 @@ public interface PassiveSessionAuthenticator {
|
|||
|
||||
String providerCode();
|
||||
|
||||
default String displayName() {
|
||||
return providerCode();
|
||||
}
|
||||
|
||||
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,9 @@ public interface DirectAuthProvider {
|
|||
|
||||
String providerCode();
|
||||
|
||||
default String displayName() {
|
||||
return providerCode();
|
||||
}
|
||||
|
||||
PlatformPrincipal authenticate(DirectAuthRequest request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
10
web/src/features/auth/use-auth-methods.ts
Normal file
10
web/src/features/auth/use-auth-methods.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "和",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue