Skip to content

feat(auth): support standard CAS 2.0/3.0 SSO protocol#464

Open
dongmucat wants to merge 3 commits into
mainfrom
feature/cas-sso-protocol
Open

feat(auth): support standard CAS 2.0/3.0 SSO protocol#464
dongmucat wants to merge 3 commits into
mainfrom
feature/cas-sso-protocol

Conversation

@dongmucat
Copy link
Copy Markdown
Collaborator

概述

实现标准 CAS 2.0/3.0 协议的 SSO 登录集成,复用现有 IdentityBindingService 身份映射抽象,使 OSS 用户可以对接 Apereo CAS Server、Keycloak CAS adapter 等主流实现。

变更内容

后端实现

  • 新增 IdentityClaims 接口(provider-neutral 身份声明抽象),OAuthClaims 改为实现该接口
  • IdentityBindingService.bindOrCreate 入参从 OAuthClaims 泛化为 IdentityClaims
  • 新增 com.iflytek.skillhub.auth.cas 包:
    • CasProperties:配置类,含 @PostConstruct HTTPS 强校验 + feature flag
    • CasTicketValidator:调用 CAS server 的 serviceValidate 端点,支持 3.0 JSON 和 2.0 XML 解析
    • CasLoginController/api/v1/auth/cas/login(重定向到 CAS)和 /callback(票据验证 + 会话建立)
    • CasIdentityClaims:将 CAS attributes 适配为 IdentityClaims
    • CasValidationException:票据验证失败异常
  • RouteSecurityPolicyRegistry 放行 /api/v1/auth/cas/**
  • AuthMethodCatalog 在 CAS 启用时暴露 CAS_REDIRECT 方法类型

前端实现

  • LoginButton 组件同时渲染 OAUTH_REDIRECTCAS_REDIRECT 类型的登录按钮
  • RuntimeConfig 新增 authCasEnabled 字段
  • 30-runtime-config.sh 新增 SKILLHUB_WEB_AUTH_CAS_ENABLED 环境变量
  • 新增 cas-logo.svg

配置项

skillhub:
  auth:
    cas:
      enabled: false                    # Feature flag,默认关闭
      server-url: https://cas.example.com
      service-url: https://skillhub.example.com/api/v1/auth/cas/callback
      protocol-version: 3.0             # 2.0 | 3.0
      allow-insecure-server: false      # 开发环境 escape hatch
      attributes:
        username: uid
        display-name: cn
        email: mail

测试覆盖

  • CasTicketValidatorTest:8 个用例覆盖 JSON/XML 成功、失败、缺失字段、数组属性、禁用状态
  • CasLoginControllerTest:12 个用例覆盖重定向、回调成功/失败、returnTo 处理、账户状态异常

安全考虑

  • CAS server URL 默认强制 HTTPS,需显式设置 allow-insecure-server=true 才能用 HTTP(仅限开发)
  • 复用 OAuthLoginRedirectSupport.sanitizeReturnTo() 防止 open redirect
  • 票据验证通过 server-to-server 调用完成,ticket 不暴露给前端
  • Session fixation 防护由 PlatformSessionService.establishSession 内置处理

相关 Issue

Closes #456

测试说明

本地验证步骤

  1. application.yml 中启用 CAS 并配置 CAS server 地址
  2. 启动后端,访问 /api/v1/auth/methods 确认返回 CAS 方法
  3. 点击前端 CAS 登录按钮,验证重定向到 CAS server
  4. CAS 认证后回调,验证 session 建立成功

回归测试范围

  • OAuth 登录流程(GitHub/GitLab)不受影响
  • 本地密码登录不受影响
  • Direct auth / Session bootstrap 不受影响

dongmucat added 2 commits May 27, 2026 12:43
Implement native CAS protocol ticket validation for enterprise SSO
integration, supporting both CAS 2.0 (XML) and CAS 3.0 (JSON) modes.

Backend:
- Introduce IdentityClaims interface to abstract identity providers;
  OAuthClaims now implements it, enabling CAS reuse of IdentityBindingService
- CasProperties with @PostConstruct HTTPS validation and feature flag
- CasTicketValidator: validates tickets via /serviceValidate (2.0) or
  /p3/serviceValidate (3.0), parses XML/JSON responses
- CasLoginController: /api/v1/auth/cas/login (redirect) and /callback
  (ticket validation + session establishment)
- RouteSecurityPolicyRegistry: permit /api/v1/auth/cas/**
- AuthMethodCatalog: expose CAS as CAS_REDIRECT method type

Frontend:
- LoginButton renders CAS_REDIRECT methods alongside OAuth providers
- Runtime config adds authCasEnabled flag
- CAS logo SVG added

Closes #456
…ase wiring

Blockers:
- Harden XML parsing against XXE (disallow DOCTYPE, external entities/DTDs,
  enable FEATURE_SECURE_PROCESSING) and switch to UTF-8 byte decoding.
- Generalize AccessPolicy.evaluate from OAuthClaims to IdentityClaims; extract
  IdentityAuthenticator so OAuth and CAS share allow/deny/pending evaluation.
  CAS callback now goes through the policy instead of bypassing it with a
  direct bindOrCreate call.
- Configure JDK HttpClient with connect/read timeouts (5s/10s) and disable
  HTTP redirects to prevent ticket exfiltration via a malicious CAS server.

Major:
- Require HTTPS for skillhub.auth.cas.service-url in addition to server-url.
- Stop logging raw service tickets; log claims.subject() instead.
- Remove the dead authCasEnabled web flag — the backend AuthMethodCatalog is
  the single source of truth for CAS visibility, matching how OAuth works.
- Wire SKILLHUB_AUTH_CAS_* env vars into compose.release.yml and add a fully
  documented section in .env.release.example.

Minor:
- CasProtocolVersion enum replaces string comparisons in the validator.
- JSON multi-value array attributes are preserved as List<String> instead of
  silently dropping all but the first element.
- AuthMethod.methodType union adds 'CAS_REDIRECT'.
- application.yml notes that service-url must equal
  ${SKILLHUB_PUBLIC_BASE_URL}/api/v1/auth/cas/callback.

Tests:
- CasTicketValidatorTest tightens URL matching to assert ticket/service/format
  parameters and adds XXE + billion-laughs regression cases.
- IdentityAuthenticatorTest covers ALLOW / PENDING / DENY paths.
- AuthMethodCatalogTest exercises both cas.enabled=true and =false.
- isExternalRedirectMethod predicate extracted and unit-tested.
@dongmucat
Copy link
Copy Markdown
Collaborator Author

Pushed ace0cb2d addressing the review.

Blockers

  • B1 — DocumentBuilderFactory now sets FEATURE_SECURE_PROCESSING, disallow-doctype-decl, disables external general/parameter entities and external DTDs; XML body is decoded as UTF-8. Regression covered by CasTicketValidatorTest#validate_cas20_xml_xxePayload_isRejected and validate_cas20_xml_billionLaughs_isRejected.
  • B2 — AccessPolicy.evaluate is now typed on IdentityClaims; all four implementations (Open, EmailDomain, ProviderAllowlist, SubjectWhitelist) updated. Extracted IdentityAuthenticator so the same allow/deny/pending pipeline runs for OAuth and CAS — CasLoginController no longer calls bindOrCreate directly. Added IdentityAuthenticatorTest for the three decision branches.
  • B3 — RestClient is built from a JDK HttpClient configured with connectTimeout=5s, readTimeout=10s, and Redirect.NEVER. Added small package-private constructor for tests.

Major

  • serviceUrl HTTPS check added to CasProperties.@PostConstruct (same allow-insecure-server escape hatch as serverUrl).
  • Logs no longer carry raw tickets — switched to claims.subject() after validation.
  • Dropped the dead authCasEnabled runtime flag end-to-end (template, client.ts, bootstrap.ts, 30-runtime-config.sh). Visibility is fully owned by /api/v1/auth/methods, matching OAuth.
  • compose.release.yml now passes SKILLHUB_AUTH_CAS_* to the server container; .env.release.example has a documented CAS section explaining the service-url = ${SKILLHUB_PUBLIC_BASE_URL}/api/v1/auth/cas/callback rule.

Minor

  • CasProtocolVersion enum replaces string comparisons in the validator and properties.
  • JSON arrays with >1 element are preserved as List<String> instead of dropped to the first item; single-element arrays still unwrap. Username/display-name/email lookups take the first value when an attribute came in as a list.
  • AuthMethod.methodType union adds 'CAS_REDIRECT'.
  • application.yml comment explains the service-url callback path requirement.
  • Test tightening: CasTicketValidatorTest now asserts the full validation URL (host, path, ticket=, service=, format=JSON/no-format) so a regression that drops the service parameter would fail. AuthMethodCatalogTest covers both cas.enabled=true (returns cas:CAS_REDIRECT:/api/v1/auth/cas/login) and disabled (omits CAS).
  • Frontend: extracted isExternalRedirectMethod(method) predicate and unit-tested OAuth/CAS/local/direct/bootstrap inputs in login-button.test.ts.

Quality gates

  • make test-backend-app — 472 tests, 0 failures.
  • make typecheck-web / make lint-web — 0 errors / 0 warnings.
  • make test-frontend — 582 tests pass.

Open follow-ups not in scope here:

  • IdentityClaims.extra is still a dumping ground (avatar URL leakage from OAuth into the binding service). Worth a separate refactor; flagging here so it doesn't get lost.
  • 30-runtime-config.sh's envsubst doesn't escape JS string context — pre-existing across all flags. Suggest tracking as its own issue.

…kage, CSRF

B1 — Ticket log leak: log.debug now records only the validation path
(resolvedProtocolVersion().validatePath()); the catch block surfaces only
e.getClass().getSimpleName() instead of e.getMessage(), preventing the
full validation URL (with ticket query param) from reaching log streams.

B2 — emailVerified semantics: CasIdentityClaims.emailVerified() now
constantly returns false. CAS passes through email attributes from the
upstream directory (LDAP/AD) without cryptographic verification, so
returning true was a false signal to any AccessPolicy that gates on it.

B3 — Exception package: AccountPendingException and AccountDisabledException
moved from auth.oauth to auth.identity; all import sites updated. OAuth,
CAS, and future SAML/OIDC flows now import from the neutral package.

S6 — Login CSRF via state nonce: login() generates a cryptographically
random 24-byte nonce, stores it in the session under a CAS-specific key
(skillhub.cas.state), and appends state=<nonce> to the service URL that
is sent to the CAS server. callback() validates the incoming state param
against the session value before touching the ticket; a mismatch short-
circuits to redirect:/login?error=invalid_state. The CAS-specific session
key (skillhub.cas.state / skillhub.cas.returnTo) also eliminates the
previous shared-key concurrency hazard with the OAuth flow.
@dongmucat
Copy link
Copy Markdown
Collaborator Author

Pushed 821c7e13 addressing the three blockers + S6.

B1 — Ticket log leak

  • log.debug reduced to resolvedProtocolVersion().validatePath() (no query string).
  • catch block surfaces e.getClass().getSimpleName() rather than e.getMessage(), so the ?ticket=ST-... URL the RestClient wraps in its exception text never reaches downstream logs / error redirects. Full stack trace is still emitted at log.error for operators.

B2 — emailVerified semantics

  • CasIdentityClaims.emailVerified() now constantly returns false. CAS passes the email through from the upstream directory without cryptographic proof, so the previous email != null && !email.isBlank() was a false signal to any AccessPolicy gating on it. Documented in a comment.

B3 — Exception package

  • AccountPendingException and AccountDisabledException moved from auth.oauth to auth.identity. All import sites updated (OAuth flow service, OAuth login handlers, IdentityBindingService FQCN throws, CAS controller, identity authenticator, plus tests). OAuth/CAS/future SAML/OIDC flows now import from the neutral package.

S6 — Login CSRF via state nonce

  • login() generates a 24-byte SecureRandom nonce, stores it under the CAS-specific session key skillhub.cas.state, and appends state=<nonce> to the service URL we send to the CAS server. The CAS server then redirects back with ?state=<nonce>&ticket=ST-....
  • callback() validates state against the session value before invoking the ticket validator; mismatches short-circuit to redirect:/login?error=invalid_state. New tests cover missing state, wrong state, and no-session scenarios.
  • As a side benefit, switching to skillhub.cas.state / skillhub.cas.returnTo eliminates the previous shared-key concurrency hazard with the OAuth flow (S3).

Quality gates

  • make test-backend-app — 472 passed.
  • make typecheck-web / make lint-web — 0 errors / 0 warnings.
  • make test-frontend — 582 passed.

Remaining Should fix items (S1/S2/S4/S5) and the documentation update are tracked for a follow-up PR per the agreed scope.

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.

feat(auth): support standard CAS 2.0/3.0 SSO protocol

1 participant