Skip to content

feat(auth): add CAS-based SSO login support#444

Open
jangrui wants to merge 4 commits into
iflytek:mainfrom
jangrui:feature/sso-cas-integration
Open

feat(auth): add CAS-based SSO login support#444
jangrui wants to merge 4 commits into
iflytek:mainfrom
jangrui:feature/sso-cas-integration

Conversation

@jangrui
Copy link
Copy Markdown

@jangrui jangrui commented May 15, 2026

概要

基于 CAS 协议接入企业 SSO 登录:

  • 后端:SSO 重定向与票据验证,首次登录自动创建平台账号
  • 前端:登录页新增"企业 SSO 登录"按钮,由运行时配置控制显隐
  • 配置:所有 SSO 服务端地址和字段映射通过 SsoProperties 外部注入
  • Bug 修复:web/docker-entrypoint.d/30-runtime-config.sh 现在替换所有 9 个运行时配置变量

变更清单

后端

  • SsoProperties.java — SSO 配置属性类
  • SsoClient.java — 向 SSO 服务端 POST 验证 CAS 票据(超时通过 SimpleClientHttpRequestFactory 配置)
  • SsoUser.java — SSO 用户信息载体(account / id / name)
  • TicketValidationException.java — 票据验证异常
  • SsoIdentityService.java — SSO 身份到平台用户的映射与自动建号
  • SsoLoginController.java — /login(重定向到 SSO)和 /callback(票据验证 + 建立会话)
  • SsoClientTest.java — SsoClient 单元测试
  • SsoIdentityServiceTest.java — 身份映射与自动建号测试
  • SsoLoginControllerTest.java — 控制器 redirect/error 处理测试
  • RouteSecurityPolicyRegistry.java — 放行 /api/v1/auth/sso/** 路由

前端

  • sso-login-entry.tsx — SSO 登录按钮组件
  • client.ts — getSsoRuntimeConfig() 从运行时配置读取 authSsoEnabled
  • login.tsx — SSO 按钮接入登录页
  • login.test.tsx — 更新 mock 包含 getSsoRuntimeConfig
  • i18n:zh.json / en.json — 企业 SSO 登录 / Enterprise SSO Login

配置与修复

  • .env.release.example — 添加 SKILLHUB_AUTH_SSO_* 和 SKILLHUB_WEB_AUTH_SSO_ENABLED
  • 30-runtime-config.sh — 修复:现在替换全部 9 个运行时配置变量
  • runtime-config.js.template — 添加 authSsoEnabled
  • SsoProperties.java — validatePath 无默认值,需显式配置

测试计划

  • 后端:SsoClientTest(7 用例)、SsoIdentityServiceTest(6 用例)、SsoLoginControllerTest(9 用例),共 22 个新增单元测试全部通过
  • 前端:Docker 内 vitest run — 179 个文件,577 个用例,全部通过
  • 前端:Docker 构建运行 SKILLHUB_WEB_AUTH_SSO_ENABLED=true — /login 页面 SSO 按钮渲染正常

Add backend CAS callback endpoints (redirect → validate ticket → establish
session), frontend SSO login button, and runtime config plumbing. All SSO
server URLs are externalized via SsoProperties with zero company-specific
defaults.

Also fix a pre-existing bug in web/docker-entrypoint.d/30-runtime-config.sh
where auth-related runtime config env vars were not being substituted into
runtime-config.js.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces CAS-based SSO authentication, including backend services for ticket validation and identity resolution, and a frontend login button. Key feedback from the review includes the need to preserve the 'returnTo' parameter across the login redirect and callback to improve user experience, the addition of timeouts to the SSO client's HTTP requests to avoid potential thread exhaustion, and the use of immutable user identifiers for identity bindings to prevent issues with mutable usernames.

Comment on lines +53 to +65
@GetMapping("/login")
public void ssoLogin(HttpServletResponse response) throws IOException {
if (!properties.isEnabled()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
return;
}
String ssoLoginUrl = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
.path("/login")
.queryParam("clientUrl", properties.getClientUrl())
.build()
.toUriString();
response.sendRedirect(ssoLoginUrl);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The SSO login initiation does not preserve the returnTo destination. Users will always be redirected to the root path after login, which is a poor experience for enterprise users who expect to return to their original page. The /login endpoint should accept a returnTo parameter and store it in the session to be used during the callback.

    /**
     * Initiates SSO login by redirecting the browser to the SSO login page.
     */
    @GetMapping("/login")
    public void ssoLogin(@RequestParam(value = "returnTo", required = false) String returnTo,
                         HttpServletRequest request,
                         HttpServletResponse response) throws IOException {
        if (!properties.isEnabled()) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
            return;
        }
        if (returnTo != null && !returnTo.isBlank() && returnTo.startsWith("/")) {
            request.getSession().setAttribute("sso_return_to", returnTo);
        }
        String ssoLoginUrl = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
                .path("/login")
                .queryParam("clientUrl", properties.getClientUrl())
                .build()
                .toUriString();
        response.sendRedirect(ssoLoginUrl);
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 8759388 修复。ssoLogin 端点新增可选 returnTo 参数,将其存入 session 中,供后续 /callback 使用。

Comment on lines +72 to +92
@GetMapping("/callback")
public void ssoCallback(@RequestParam("ticket") String ticket,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
if (!properties.isEnabled()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
return;
}

try {
SsoUser ssoUser = ssoClient.validateTicket(ticket);
var principal = ssoIdentityService.resolveOrCreate(ssoUser);
platformSessionService.establishSession(principal, request);
response.sendRedirect("/");
} catch (TicketValidationException e) {
log.warn("SSO ticket validation failed: {}", e.getMessage());
response.sendRedirect("/login?error=sso_auth_failed");
} catch (Exception e) {
log.error("SSO callback error", e);
response.sendRedirect("/login?error=sso_error");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The callback logic should retrieve the returnTo path from the session to ensure the user is redirected back to their original destination after successful authentication.

    @GetMapping("/callback")
    public void ssoCallback(@RequestParam("ticket") String ticket,
                            HttpServletRequest request,
                            HttpServletResponse response) throws IOException {
        if (!properties.isEnabled()) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
            return;
        }

        try {
            SsoUser ssoUser = ssoClient.validateTicket(ticket);
            var principal = ssoIdentityService.resolveOrCreate(ssoUser);
            platformSessionService.establishSession(principal, request);

            var returnTo = (String) request.getSession().getAttribute("sso_return_to");
            request.getSession().removeAttribute("sso_return_to");
            response.sendRedirect(returnTo != null ? returnTo : "/");
        } catch (TicketValidationException e) {
            log.warn("SSO ticket validation failed: {}", e.getMessage());
            response.sendRedirect("/login?error=sso_auth_failed");
        } catch (Exception e) {
            log.error("SSO callback error", e);
            response.sendRedirect("/login?error=sso_error");
        }
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 8759388 修复。/callback 现在从 session 中取出之前存入的 returnTo 进行重定向,未设置时回退到 /。

Comment on lines +21 to +24
public SsoClient(SsoProperties properties, RestTemplateBuilder restTemplateBuilder) {
this.properties = properties;
this.restTemplate = restTemplateBuilder.build();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The RestTemplate used for ticket validation is built without any timeouts. In an enterprise environment, if the SSO server is slow or unresponsive, this can lead to thread exhaustion in the application. It is highly recommended to configure connection and read timeouts.

    public SsoClient(SsoProperties properties, RestTemplateBuilder restTemplateBuilder) {
        this.properties = properties;
        this.restTemplate = restTemplateBuilder
                .setConnectTimeout(java.time.Duration.ofSeconds(5))
                .setReadTimeout(java.time.Duration.ofSeconds(10))
                .build();
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 8759388 修复。通过 RestTemplateBuilder 配置 connectTimeout=5s 和 readTimeout=10s,避免 SSO 服务端无响应时线程耗尽。

Comment on lines +49 to +51
IdentityBinding binding = bindingRepo
.findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.account())
.orElse(null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The identity binding currently uses ssoUser.account() (the username) as the lookup key. Usernames in enterprise systems are often mutable (e.g., due to name changes), whereas the id (employee/user identifier) is typically immutable. Using a mutable identifier for the binding subject can lead to duplicate accounts or users losing access if their username changes. It is recommended to use ssoUser.id() as the subject.

Suggested change
IdentityBinding binding = bindingRepo
.findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.account())
.orElse(null);
IdentityBinding binding = bindingRepo
.findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.id())
.orElse(null);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 8759388 修复。findByProviderCodeAndSubject 改用不可变的 ssoUser.id()(员工 ID)作为查询键,而非可变的 ssoUser.account()(用户名可更改)。

Comment on lines +71 to +73
binding = new IdentityBinding(user.getId(), PROVIDER_CODE,
ssoUser.account(), ssoUser.account());
bindingRepo.save(binding);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When creating a new identity binding, ensure the immutable id is used as the subject to maintain identity integrity across username changes.

Suggested change
binding = new IdentityBinding(user.getId(), PROVIDER_CODE,
ssoUser.account(), ssoUser.account());
bindingRepo.save(binding);
binding = new IdentityBinding(user.getId(), PROVIDER_CODE,
ssoUser.account(), ssoUser.id());
bindingRepo.save(binding);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 8759388 修复。IdentityBinding 构造时 subject 改用 ssoUser.id()(不可变员工 ID),loginName 保留 ssoUser.account()(用户可读的用户名)。

Comment on lines +22 to +24
onClick={() => {
window.location.href = '/api/v1/auth/sso/login'
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The SSO login button should pass the current returnTo path to the backend to ensure the user is redirected back to their original destination after authentication.

      onClick={() => {
        const searchParams = new URLSearchParams(window.location.search)
        const returnTo = searchParams.get('returnTo')
        const loginUrl = returnTo
          ? '/api/v1/auth/sso/login?returnTo=' + encodeURIComponent(returnTo)
          : '/api/v1/auth/sso/login'
        window.location.href = loginUrl
      }}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 8759388 修复。SSO 按钮点击时从当前 URL 读取 returnTo 参数,拼接到 /api/v1/auth/sso/login?returnTo=... 中传递给后端。

jangrui added 3 commits May 16, 2026 04:03
…ubject

- Preserve returnTo across SSO redirect flow: store in session at /login,
  restore at /callback
- Configure RestTemplate connect/read timeouts (5s/10s) for SsoClient
- Use immutable employee id (not mutable username) as identity binding
  subject; keep account as human-readable loginName
The SSO server requires the registered application token (clientToken) to
authenticate the ticket validation request. Include it in the POST body.
- Add SsoClientTest (7 tests): ticket validation, null/empty/missing
  field responses, custom response field mapping
- Add SsoIdentityServiceTest (6 tests): auto-provisioning, display name
  update, disabled user rejection, role resolution
- Add SsoLoginControllerTest (9 tests): enabled/disabled guard,
  redirect flow, returnTo preservation, error handling
- Fix SsoClient: replace RestTemplateBuilder with
  SimpleClientHttpRequestFactory for timeout config
  (RestTemplateBuilder.connectTimeout() is a valid Spring Boot 3.x
  API but caused transient build failures in Docker buildx)
@jangrui
Copy link
Copy Markdown
Author

jangrui commented May 16, 2026

@dongmucat

您好!这个 PR 添加了 CAS 单点登录(SSO)支持,包括:

  • SsoClient、SsoIdentityService、SsoLoginController 等后端组件
  • 前端 SSO 登录按钮
  • 22 个单元测试覆盖

当前有 workflows 需要您的批准才能运行,烦请批准,谢谢!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant