Skip to content

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

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

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 需要您的批准才能运行,烦请批准,谢谢!

@dongmucat
Copy link
Copy Markdown
Collaborator

@dongmucat

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

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

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

done

@XiaoSeS
Copy link
Copy Markdown
Collaborator

XiaoSeS commented May 19, 2026

Review 总结

感谢这个 PR 的工作,SSO 集成的需求是合理的,代码分层和测试覆盖也做得不错。但经过讨论,我们认为当前实现不适合合并到 OSS 主干,主要原因如下:

1. 协议兼容性问题

当前实现的 ticket 验证方式(POST {Ticket, Url, Token} → flat JSON)不是标准 CAS 协议。标准 CAS 使用 GET /serviceValidate?ticket=...&service=... 返回 XML(2.0)或 JSON(3.0 with format=JSON),且不需要 clientToken 鉴权。

这意味着 OSS 用户如果使用 Apereo CAS Server、Keycloak CAS adapter 等标准实现,无法直接对接这段代码。PR 标题写的 "CAS-based" 与实际协议不符。

2. 安全问题

  • Open RedirectreturnTo 参数未经 OAuthLoginRedirectSupport.sanitizeReturnTo() 校验,攻击者可构造恶意 returnTo 将用户重定向到钓鱼站点
  • HTTPS 未强校验baseUrl 配成 http:// 时 ticket 会明文传输

3. 架构重复

SsoIdentityService 与现有 IdentityBindingService 核心逻辑高度重复(查 binding → 建用户 → 加 namespace → 聚合角色),应复用而非新建。

建议方向

我们计划后续支持标准 CAS 2.0/3.0 协议,这样对 OSS 社区有通用价值。大致思路:

  1. 复用 IdentityBindingService,将 CAS attributes 适配为 IdentityClaims
  2. 实现标准 GET /serviceValidate + XML/JSON 解析
  3. 复用 OAuthLoginRedirectSupport 处理 returnTo 安全校验

如果内部部署需要对接讯飞私有 SSO 网关,建议作为独立的私有模块/overlay 维护,不进 OSS 主干。

感谢理解,期待后续标准 CAS 的实现合作!

@XiaoSeS
Copy link
Copy Markdown
Collaborator

XiaoSeS commented May 19, 2026

关闭此 PR。后续计划以标准 CAS 2.0/3.0 协议重新实现 SSO 集成,复用现有 IdentityBindingService 抽象。详见上方 review 评论。

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.

3 participants