feat(auth): add CAS-based SSO login support#444
Conversation
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.
There was a problem hiding this comment.
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.
| @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); | ||
| } |
There was a problem hiding this comment.
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);
}There was a problem hiding this comment.
已在 8759388 修复。ssoLogin 端点新增可选 returnTo 参数,将其存入 session 中,供后续 /callback 使用。
| @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"); | ||
| } |
There was a problem hiding this comment.
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");
}
}There was a problem hiding this comment.
已在 8759388 修复。/callback 现在从 session 中取出之前存入的 returnTo 进行重定向,未设置时回退到 /。
| public SsoClient(SsoProperties properties, RestTemplateBuilder restTemplateBuilder) { | ||
| this.properties = properties; | ||
| this.restTemplate = restTemplateBuilder.build(); | ||
| } |
There was a problem hiding this comment.
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();
}There was a problem hiding this comment.
已在 8759388 修复。通过 RestTemplateBuilder 配置 connectTimeout=5s 和 readTimeout=10s,避免 SSO 服务端无响应时线程耗尽。
| IdentityBinding binding = bindingRepo | ||
| .findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.account()) | ||
| .orElse(null); |
There was a problem hiding this comment.
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.
| IdentityBinding binding = bindingRepo | |
| .findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.account()) | |
| .orElse(null); | |
| IdentityBinding binding = bindingRepo | |
| .findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.id()) | |
| .orElse(null); |
There was a problem hiding this comment.
已在 8759388 修复。findByProviderCodeAndSubject 改用不可变的 ssoUser.id()(员工 ID)作为查询键,而非可变的 ssoUser.account()(用户名可更改)。
| binding = new IdentityBinding(user.getId(), PROVIDER_CODE, | ||
| ssoUser.account(), ssoUser.account()); | ||
| bindingRepo.save(binding); |
There was a problem hiding this comment.
When creating a new identity binding, ensure the immutable id is used as the subject to maintain identity integrity across username changes.
| 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); |
There was a problem hiding this comment.
已在 8759388 修复。IdentityBinding 构造时 subject 改用 ssoUser.id()(不可变员工 ID),loginName 保留 ssoUser.account()(用户可读的用户名)。
| onClick={() => { | ||
| window.location.href = '/api/v1/auth/sso/login' | ||
| }} |
There was a problem hiding this comment.
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
}}
There was a problem hiding this comment.
已在 8759388 修复。SSO 按钮点击时从当前 URL 读取 returnTo 参数,拼接到 /api/v1/auth/sso/login?returnTo=... 中传递给后端。
…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)
|
您好!这个 PR 添加了 CAS 单点登录(SSO)支持,包括:
当前有 workflows 需要您的批准才能运行,烦请批准,谢谢! |
done |
Review 总结感谢这个 PR 的工作,SSO 集成的需求是合理的,代码分层和测试覆盖也做得不错。但经过讨论,我们认为当前实现不适合合并到 OSS 主干,主要原因如下: 1. 协议兼容性问题当前实现的 ticket 验证方式(POST 这意味着 OSS 用户如果使用 Apereo CAS Server、Keycloak CAS adapter 等标准实现,无法直接对接这段代码。PR 标题写的 "CAS-based" 与实际协议不符。 2. 安全问题
3. 架构重复
建议方向我们计划后续支持标准 CAS 2.0/3.0 协议,这样对 OSS 社区有通用价值。大致思路:
如果内部部署需要对接讯飞私有 SSO 网关,建议作为独立的私有模块/overlay 维护,不进 OSS 主干。 感谢理解,期待后续标准 CAS 的实现合作! |
|
关闭此 PR。后续计划以标准 CAS 2.0/3.0 协议重新实现 SSO 集成,复用现有 IdentityBindingService 抽象。详见上方 review 评论。 |
概要
基于 CAS 协议接入企业 SSO 登录:
变更清单
后端
前端
配置与修复
测试计划