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 需要您的批准才能运行,烦请批准,谢谢! |
概要
基于 CAS 协议接入企业 SSO 登录:
变更清单
后端
前端
配置与修复
测试计划