From 2c1bcf3b22ea640d15a1077fdc5b4c673c27f8ae Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:01:38 +0800 Subject: [PATCH] fix(auth): harden CAS identity link flow Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/21-unified-identity-federation-design.md | 12 +- docs/23-cas-integration.md | 9 + scripts/tests/nginx-forwarded-proto-test.sh | 12 + .../controller/CasLoginController.java | 17 ++ .../skillhub/filter/RequestLoggingFilter.java | 26 +- .../security/SensitiveLogSanitizer.java | 16 +- .../skillhub/service/CasLoginAppService.java | 247 +++++++++++++++++- .../skillhub/service/CasLoginFailure.java | 1 + .../skillhub/service/CasLoginStateStore.java | 85 +++++- .../service/IdentityLinkAppService.java | 71 ++++- .../controller/CasLoginControllerTest.java | 26 ++ .../filter/RequestLoggingFilterTest.java | 53 +++- .../security/SensitiveLogSanitizerTest.java | 25 +- .../service/CasLoginAppServiceTest.java | 201 +++++++++++++- .../service/CasLoginStateStoreTest.java | 50 +++- .../service/IdentityLinkAppServiceTest.java | 198 ++++++++++++++ .../skillhub/auth/cas/CasProtocolClient.java | 142 ++++++++-- .../identity/IdentityLinkSessionManager.java | 23 +- .../identity/IdentityLinkTransaction.java | 28 +- .../policy/RouteSecurityPolicyRegistry.java | 7 +- .../CasProtocolClientHttpIntegrationTest.java | 43 +++ ...ntityLinkTransactionCasCapabilityTest.java | 137 ++++++++++ .../RouteSecurityPolicyRegistryTest.java | 22 +- web/nginx.conf.template | 6 + web/src/api/generated/schema.d.ts | 83 +++++- web/src/api/types.ts | 9 +- .../auth/identity-link-manager.test.tsx | 27 ++ .../features/auth/identity-link-manager.tsx | 13 +- web/src/features/auth/login-failure.test.ts | 2 + web/src/features/auth/login-failure.ts | 1 + web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + 32 files changed, 1461 insertions(+), 133 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java diff --git a/docs/21-unified-identity-federation-design.md b/docs/21-unified-identity-federation-design.md index 0c3d4478..ae8ced4f 100644 --- a/docs/21-unified-identity-federation-design.md +++ b/docs/21-unified-identity-federation-design.md @@ -1633,10 +1633,12 @@ Provider capability 检查、Binding/Subject 唯一性、账号资格和审计 - 操作类型、目标 Provider、可选目标 Binding; - 固定过期时间和一次性状态。 -当前账号证明与目标 Provider 证明必须分开。Browser Provider 使用现有 OAuth state -校验并保留主 Platform Session;Credential Provider 只在 Adapter 中校验凭据, -只把 `ProviderAuthenticationResult` 交给核心。密码、OAuth code/token、ticket、 -Cookie、原始 Session ID/nonce 和 proof 不进入 DTO、数据库、审计或日志。 +当前账号证明与目标 Provider 证明必须分开。Browser Provider 使用协议自身的一次性 +state 校验并保留主 Platform Session:OAuth 复用 Spring Security authorization request +state,CAS 使用 Redis 原子消费 state 并把 Ticket 绑定到精确 service URL。Credential +Provider 只在 Adapter 中校验凭据,只把 `ProviderAuthenticationResult` 交给核心。 +密码、OAuth code/token、CAS ticket、Cookie、原始 Session ID/nonce 和 proof 不进入 +DTO、数据库、审计或日志。 公开 API: @@ -1918,6 +1920,8 @@ GET login start 必须在设计和协议测试中记录证据。 - Ticket validation 必须绑定发起时精确 service URL。 - state 和 Ticket 一次性消费。 +- CAS Browser Flow 必须接入统一 Identity Link intent,支持当前账号重新认证和目标 + Provider 绑定;不得在 CAS Adapter 内直接写 Binding。 - CAS 2 XML 禁止 XXE。 - CAS 3 JSON/XML 错误正确分类。 - principal 或 immutable attribute 必须明确配置为稳定 Subject。 diff --git a/docs/23-cas-integration.md b/docs/23-cas-integration.md index 8862ef31..f9e54b29 100644 --- a/docs/23-cas-integration.md +++ b/docs/23-cas-integration.md @@ -132,10 +132,19 @@ attribute,并为该属性选择稳定的 `subject-type`。属性缺失、多 第二个 Session。 5. 检查应用日志中不包含 `ST-` Ticket、validation URL、上游完整响应或用户属性。 6. 禁用 Provider 后,方法目录不再显示 CAS,且点击旧 URL 不应连接 CAS Server。 +7. 在账号安全页验证 CAS Identity Link:已绑定 CAS 可以完成 fresh reauthentication, + READY intent 可以通过目标 CAS 创建 Binding;失败后应回到同一 intent,成功后不能重放。 建议分别验证 CAS 2 XML、CAS 3 JSON、CAS 3 XML fallback、无效 Ticket、错误 Service、 超时、TLS 失败、XXE、超大响应和 Redis 不可用。 +CAS callback 按协议会在 query string 中携带一次性 `ticket` 和 `state`。官方 Web +镜像不记录 query string 或 Referer,应用请求日志会对它们进行隐藏;如果前面还有 +Ingress、负载均衡、WAF、APM 或其他反向代理,必须将该 callback 路径的 query string +和 Referer 关闭记录或至少对 `ticket`、`state` 脱敏。上线前应使用唯一测试 Ticket +检查完整日志链路,确认没有原文残留。重复 state 会分类为 `REPLAY_DETECTED` 并写入 +不含 state/Ticket 的安全审计。 + ## 5. 升级与回滚 - 新配置默认关闭,不影响现有本地密码、GitHub、GitLab 或 OIDC 登录。 diff --git a/scripts/tests/nginx-forwarded-proto-test.sh b/scripts/tests/nginx-forwarded-proto-test.sh index 01be85c3..c0ef65a1 100755 --- a/scripts/tests/nginx-forwarded-proto-test.sh +++ b/scripts/tests/nginx-forwarded-proto-test.sh @@ -98,4 +98,16 @@ done assert_proto "$TRUSTED_PROXY" http assert_proto "$TRUSTED_PROXY" http "https,http" +LOG_TICKET="ST-nginx-log-secret-$$" +LOG_STATE="nginx-state-secret-$$" +docker exec "$DEFAULT_PROXY" wget -qO- \ + --header="Referer: https://skillhub.test/api/v1/auth/cas/cas-main/callback?ticket=$LOG_TICKET&state=$LOG_STATE" \ + "http://127.0.0.1/api/proto?ticket=$LOG_TICKET&state=$LOG_STATE" \ + >/dev/null +NGINX_ACCESS_LOG="$(docker logs "$DEFAULT_PROXY" 2>&1)" +[[ "$NGINX_ACCESS_LOG" != *"$LOG_TICKET"* ]] \ + || fail "nginx access log exposed a CAS ticket" +[[ "$NGINX_ACCESS_LOG" != *"$LOG_STATE"* ]] \ + || fail "nginx access log exposed a CAS state" + echo "nginx-forwarded-proto-test passed" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java index 49620397..22299c29 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java @@ -3,6 +3,9 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.service.CasLoginAppService; import com.iflytek.skillhub.service.CasLoginFailure; import com.iflytek.skillhub.service.CasLoginFlowException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import org.slf4j.Logger; @@ -35,6 +38,12 @@ public class CasLoginController { } @GetMapping("/login") + @Operation(summary = "Start a CAS browser login") + @ApiResponses({ + @ApiResponse( + responseCode = "302", + description = "Redirect to the configured CAS login endpoint") + }) public ResponseEntity login( @PathVariable String providerCode, @RequestParam(required = false) String returnTo, @@ -54,6 +63,12 @@ public class CasLoginController { } @GetMapping("/callback") + @Operation(summary = "Complete a CAS browser login") + @ApiResponses({ + @ApiResponse( + responseCode = "302", + description = "Redirect to the authenticated target or login failure page") + }) public ResponseEntity callback( @PathVariable String providerCode, @RequestParam(required = false) String ticket, @@ -88,6 +103,8 @@ public class CasLoginController { URI.create("/login?reason=linkRequired"); case INVALID_STATE -> URI.create("/login?reason=casInvalidState"); + case REPLAY_DETECTED -> + URI.create("/login?reason=casReplayDetected"); case TICKET_MISSING -> URI.create("/login?reason=casTicketMissing"); case VALIDATION_FAILED -> diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index d46a1e49..b1aabcc1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.filter; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -35,6 +36,15 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_SUFFIXES = Set.of( "/sse" ); + private static final Set SENSITIVE_BODY_PREFIXES = Set.of( + "/api/v1/auth/" + ); + private final SensitiveLogSanitizer sensitiveLogSanitizer; + + public RequestLoggingFilter( + SensitiveLogSanitizer sensitiveLogSanitizer) { + this.sensitiveLogSanitizer = sensitiveLogSanitizer; + } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -66,15 +76,14 @@ public class RequestLoggingFilter extends OncePerRequestFilter { } private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) { - String requestUri = request.getRequestURI(); - String queryString = request.getQueryString(); - String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri; + String requestTarget = + sensitiveLogSanitizer.sanitizeRequestTarget(request); String contentType = request.getContentType(); String userAgent = request.getHeader("User-Agent"); StringBuilder sb = new StringBuilder(); - sb.append(request.getMethod()).append(" ").append(fullUrl); + sb.append(request.getMethod()).append(" ").append(requestTarget); sb.append(" | ").append(response.getStatus()); sb.append(" | ").append(duration).append("ms"); sb.append(" | ").append(request.getRemoteAddr()); @@ -85,7 +94,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter { sb.append(" | UA: ").append(truncate(userAgent, 80)); } - String requestBody = getRequestBody(request); + String requestBody = shouldLogBody(request.getRequestURI()) + ? getRequestBody(request) + : null; if (requestBody != null && !requestBody.isBlank()) { sb.append(" | Body: ").append(requestBody); } @@ -107,6 +118,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return false; } + private boolean shouldLogBody(String uri) { + return SENSITIVE_BODY_PREFIXES.stream() + .noneMatch(uri::startsWith); + } + private boolean isNotificationSse(String uri) { return uri != null && uri.endsWith("/notifications/sse"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java index ce38aea7..5f183c75 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.security; import jakarta.servlet.http.HttpServletRequest; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Locale; import java.util.Set; @@ -16,7 +18,8 @@ public class SensitiveLogSanitizer { private static final Set SENSITIVE_KEYS = Set.of( "password", "passwd", "pwd", "token", "authorization", "cookie", - "secret", "api_key", "apikey", "access_key", "refresh_token", "code"); + "secret", "api_key", "apikey", "access_key", "refresh_token", + "code", "ticket", "state"); public String sanitizeRequestTarget(HttpServletRequest request) { String uri = request.getRequestURI(); @@ -39,7 +42,16 @@ public class SensitiveLogSanitizer { return queryPart; } String key = queryPart.substring(0, idx); - String normalizedKey = key.trim().toLowerCase(Locale.ROOT); + String normalizedKey; + try { + normalizedKey = URLDecoder.decode( + key, + StandardCharsets.UTF_8) + .trim() + .toLowerCase(Locale.ROOT); + } catch (IllegalArgumentException exception) { + return key + "=[REDACTED]"; + } if (SENSITIVE_KEYS.contains(normalizedKey)) { return key + "=[REDACTED]"; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java index c66872c1..67986c7d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java @@ -4,19 +4,36 @@ import com.iflytek.skillhub.auth.cas.CasAuthenticationExchange; import com.iflytek.skillhub.auth.cas.CasBrowserClient; import com.iflytek.skillhub.auth.cas.CasLoginInitiation; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService; import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase; +import com.iflytek.skillhub.auth.identity.IdentityLinkException; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import java.net.URI; import java.security.SecureRandom; import java.util.Base64; +import java.util.Optional; +import java.util.UUID; import java.util.function.Supplier; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -27,14 +44,21 @@ import org.springframework.stereotype.Service; @Service public class CasLoginAppService { + private static final Logger log = LoggerFactory.getLogger( + CasLoginAppService.class); private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Pattern PROVIDER_CODE_PATTERN = + Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); private final IdentityProviderRegistry providerRegistry; private final CasBrowserClient protocolClient; private final ProviderLoginAppService providerLoginAppService; + private final ExternalIdentityLinkService externalIdentityLinkService; + private final IdentityLinkSessionManager identityLinkSessionManager; private final PlatformSessionService platformSessionService; private final CasLoginStateStore stateStore; + private final AuditLogService auditLogService; private final Supplier stateSupplier; @Autowired @@ -42,14 +66,20 @@ public class CasLoginAppService { IdentityProviderRegistry providerRegistry, CasBrowserClient protocolClient, ProviderLoginAppService providerLoginAppService, + ExternalIdentityLinkService externalIdentityLinkService, + IdentityLinkSessionManager identityLinkSessionManager, PlatformSessionService platformSessionService, - CasLoginStateStore stateStore) { + CasLoginStateStore stateStore, + AuditLogService auditLogService) { this( providerRegistry, protocolClient, providerLoginAppService, + externalIdentityLinkService, + identityLinkSessionManager, platformSessionService, stateStore, + auditLogService, CasLoginAppService::newState); } @@ -57,14 +87,20 @@ public class CasLoginAppService { IdentityProviderRegistry providerRegistry, CasBrowserClient protocolClient, ProviderLoginAppService providerLoginAppService, + ExternalIdentityLinkService externalIdentityLinkService, + IdentityLinkSessionManager identityLinkSessionManager, PlatformSessionService platformSessionService, CasLoginStateStore stateStore, + AuditLogService auditLogService, Supplier stateSupplier) { this.providerRegistry = providerRegistry; this.protocolClient = protocolClient; this.providerLoginAppService = providerLoginAppService; + this.externalIdentityLinkService = externalIdentityLinkService; + this.identityLinkSessionManager = identityLinkSessionManager; this.platformSessionService = platformSessionService; this.stateStore = stateStore; + this.auditLogService = auditLogService; this.stateSupplier = stateSupplier; } @@ -92,6 +128,10 @@ public class CasLoginAppService { initiation.serviceUrl(), OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo), initiation.stateTtl()); + identityLinkSessionManager.activateBrowserFlow( + session, + providerCode, + state); } catch (CasLoginStateStore.CasLoginStateStoreException exception) { throw failure(CasLoginFailure.INTERNAL_ERROR); } @@ -105,7 +145,24 @@ public class CasLoginAppService { HttpServletRequest request) { CasLoginStateStore.CasLoginState loginState = consumeState(providerCode, state, request); + IdentityLoginContext context = context(request); + Optional identityLinkFlow; + try { + identityLinkFlow = + identityLinkSessionManager.consumeBrowserFlow( + request, + providerCode, + context); + } catch (IdentityLinkException exception) { + throw failure(CasLoginFailure.INVALID_STATE); + } if (ticket == null || ticket.isBlank()) { + if (identityLinkFlow.isPresent()) { + return identityLinkFailureTarget( + identityLinkFlow.orElseThrow().intentId(), + IdentityLinkFailureCode + .PROVIDER_AUTHENTICATION_FAILED); + } throw failure(CasLoginFailure.TICKET_MISSING); } @@ -118,16 +175,53 @@ public class CasLoginAppService { ticket, loginState.serviceUrl()); var result = route.adapter().authenticate(exchange); - PlatformPrincipal principal = - providerLoginAppService.authenticate( - route.provider(), - result, - request); - platformSessionService.establishSession( - principal, - request); + if (identityLinkFlow.isPresent()) { + completeIdentityLink( + identityLinkFlow.orElseThrow(), + route, + result, + request); + } else { + PlatformPrincipal principal = + providerLoginAppService.authenticate( + route.provider(), + result, + request); + platformSessionService.establishSession( + principal, + request); + } } catch (ProviderAuthenticationException exception) { + if (exception.getReasonCode() + == ProviderAuthenticationFailureCode + .REPLAY_DETECTED) { + recordReplayAudit( + providerCode, + request, + "ticket"); + } + if (identityLinkFlow.isPresent()) { + return identityLinkFailureTarget( + identityLinkFlow.orElseThrow().intentId(), + ProviderAuthenticationFailureMapper + .mapIdentityLink(exception) + .getReasonCode()); + } throw mapProviderFailure(exception); + } catch (IdentityLinkException exception) { + if (identityLinkFlow.isPresent()) { + return identityLinkFailureTarget( + identityLinkFlow.orElseThrow().intentId(), + exception.getReasonCode()); + } + throw failure(CasLoginFailure.INTERNAL_ERROR); + } catch (IdentityCoreException exception) { + if (identityLinkFlow.isPresent()) { + return identityLinkFailureTarget( + identityLinkFlow.orElseThrow().intentId(), + mapIdentityCoreFailure(exception)); + } + throw failure(CasLoginFailure.INTERNAL_ERROR); } catch (AuthFlowException exception) { throw mapIdentityFailure(exception); } @@ -161,11 +255,25 @@ public class CasLoginAppService { } CasLoginStateStore.CasLoginState stored; try { - stored = stateStore.consume( + CasLoginStateStore.ConsumeResult result = + stateStore.consume( session.getId(), - presentedState) - .orElseThrow(() -> failure( - CasLoginFailure.INVALID_STATE)); + presentedState); + if (result.status() + == CasLoginStateStore.ConsumeStatus.REPLAYED) { + recordReplayAudit( + providerCode, + request, + "state"); + throw failure( + CasLoginFailure.REPLAY_DETECTED); + } + if (result.status() + != CasLoginStateStore.ConsumeStatus.CONSUMED) { + throw failure( + CasLoginFailure.INVALID_STATE); + } + stored = result.state(); } catch (CasLoginStateStore.CasLoginStateStoreException exception) { throw failure(CasLoginFailure.INTERNAL_ERROR); } @@ -175,6 +283,119 @@ public class CasLoginAppService { return stored; } + private void completeIdentityLink( + IdentityLinkBrowserFlow flow, + IdentityProviderRegistry.BrowserRoute + route, + ProviderAuthenticationResult result, + HttpServletRequest request) { + IdentityLinkOutcome outcome; + if (flow.phase() + == IdentityLinkBrowserPhase.REAUTHENTICATE) { + outcome = externalIdentityLinkService.reauthenticate( + flow.actor(), + flow.intentId(), + route.provider(), + result); + } else { + outcome = externalIdentityLinkService.link( + flow.actor(), + flow.intentId(), + route.provider(), + result); + } + if (outcome instanceof IdentityLinkOutcome.Reauthenticated) { + return; + } + if (outcome instanceof IdentityLinkOutcome.Linked) { + identityLinkSessionManager.remove( + request.getSession(false), + flow.intentId()); + return; + } + throw new IllegalStateException( + "Unsupported CAS identity link outcome"); + } + + private IdentityLinkFailureCode mapIdentityCoreFailure( + IdentityCoreException exception) { + IdentityFailureCode code = exception.getReasonCode(); + return switch (code) { + case PROVIDER_DISABLED, + PROVIDER_AUTHORITY_MISMATCH -> + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE; + case INVALID_IDENTITY_ASSERTION, + IDENTITY_SUBJECT_MISSING, + IDENTITY_IDENTIFIER_CONFLICT -> + IdentityLinkFailureCode + .PROVIDER_AUTHENTICATION_FAILED; + case ACCESS_DENIED, + ACCOUNT_PENDING, + ACCOUNT_DISABLED, + ACCOUNT_MERGED, + SYSTEM_ACCOUNT_FORBIDDEN -> + IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE; + }; + } + + private String identityLinkFailureTarget( + UUID intentId, + IdentityLinkFailureCode reasonCode) { + return "/settings/security?identityLink=failed" + + "&intentId=" + + intentId + + "&reasonCode=" + + reasonCode.name(); + } + + private IdentityLoginContext context( + HttpServletRequest request) { + return new IdentityLoginContext( + bounded(MDC.get("requestId"), 64), + bounded(request.getRemoteAddr(), 64), + bounded(request.getHeader("User-Agent"), 512)); + } + + private void recordReplayAudit( + String providerCode, + HttpServletRequest request, + String artifact) { + String safeProvider = providerCode != null + && PROVIDER_CODE_PATTERN.matcher(providerCode).matches() + ? providerCode + : "unresolved"; + try { + auditLogService.record( + null, + "IDENTITY_REPLAY_DETECTED", + "IDENTITY_PROVIDER", + null, + bounded(MDC.get("requestId"), 64), + bounded(request.getRemoteAddr(), 64), + bounded(request.getHeader("User-Agent"), 512), + "{\"providerCode\":\"" + + safeProvider + + "\",\"protocol\":\"cas\"," + + "\"reason\":\"REPLAY_DETECTED\"," + + "\"artifact\":\"" + + artifact + + "\"}"); + } catch (RuntimeException auditFailure) { + log.error( + "CAS replay audit failed [provider={}, failure={}]", + safeProvider, + auditFailure.getClass().getSimpleName()); + } + } + + private String bounded( + String value, + int maximumLength) { + return value == null || value.length() > maximumLength + ? null + : value; + } + private CasLoginFlowException mapProviderFailure( ProviderAuthenticationException exception) { ProviderAuthenticationFailureCode code = diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java index a9b1fbc6..d1ca1449 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java @@ -5,6 +5,7 @@ package com.iflytek.skillhub.service; */ public enum CasLoginFailure { INVALID_STATE, + REPLAY_DETECTED, TICKET_MISSING, VALIDATION_FAILED, PROVIDER_UNAVAILABLE, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java index 457377f0..603fc777 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java @@ -9,11 +9,12 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.HexFormat; +import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; /** @@ -25,8 +26,33 @@ public class CasLoginStateStore { private static final String KEY_PREFIX = "skillhub:auth:cas:state:"; + private static final String PENDING_PREFIX = "P:"; + private static final String REPLAY_MARKER = "R"; + private static final String INVALID_MARKER = "E"; private static final Pattern STATE_PATTERN = Pattern.compile("[A-Za-z0-9_-]{32,128}"); + private static final DefaultRedisScript CONSUME_SCRIPT = + new DefaultRedisScript<>( + """ + local value = redis.call('GET', KEYS[1]) + if not value then + return nil + end + if value == 'R' then + return 'R' + end + if string.sub(value, 1, 2) ~= 'P:' then + return 'E' + end + local ttl = redis.call('PTTL', KEYS[1]) + if ttl <= 0 then + redis.call('DEL', KEYS[1]) + return nil + end + redis.call('SET', KEYS[1], 'R', 'PX', ttl) + return value + """, + String.class); private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -67,7 +93,8 @@ public class CasLoginStateStore { try { redisTemplate.opsForValue().set( key(sessionId, state), - objectMapper.writeValueAsString(loginState), + PENDING_PREFIX + + objectMapper.writeValueAsString(loginState), ttl); } catch (RuntimeException | JsonProcessingException exception) { @@ -75,30 +102,38 @@ public class CasLoginStateStore { } } - Optional consume( + ConsumeResult consume( String sessionId, String state) { if (!validSessionAndState(sessionId, state)) { - return Optional.empty(); + return ConsumeResult.notFound(); } String serialized; try { - serialized = redisTemplate.opsForValue() - .getAndDelete(key(sessionId, state)); + serialized = redisTemplate.execute( + CONSUME_SCRIPT, + List.of(key(sessionId, state))); } catch (RuntimeException exception) { throw new CasLoginStateStoreException(); } if (serialized == null) { - return Optional.empty(); + return ConsumeResult.notFound(); + } + if (REPLAY_MARKER.equals(serialized)) { + return ConsumeResult.replayed(); + } + if (INVALID_MARKER.equals(serialized) + || !serialized.startsWith(PENDING_PREFIX)) { + throw new CasLoginStateStoreException(); } try { CasLoginState loginState = objectMapper.readValue( - serialized, + serialized.substring(PENDING_PREFIX.length()), CasLoginState.class); if (!loginState.expiresAt().isAfter(clock.instant())) { - return Optional.empty(); + return ConsumeResult.notFound(); } - return Optional.of(loginState); + return ConsumeResult.consumed(loginState); } catch (RuntimeException | JsonProcessingException exception) { throw new CasLoginStateStoreException(); @@ -161,6 +196,36 @@ public class CasLoginStateStore { } } + enum ConsumeStatus { + CONSUMED, + NOT_FOUND, + REPLAYED + } + + record ConsumeResult( + ConsumeStatus status, + CasLoginState state + ) { + private static ConsumeResult consumed( + CasLoginState state) { + return new ConsumeResult( + ConsumeStatus.CONSUMED, + Objects.requireNonNull(state, "state")); + } + + private static ConsumeResult notFound() { + return new ConsumeResult( + ConsumeStatus.NOT_FOUND, + null); + } + + private static ConsumeResult replayed() { + return new ConsumeResult( + ConsumeStatus.REPLAYED, + null); + } + } + static final class CasLoginStateStoreException extends RuntimeException { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java index f2592055..e62bbe6d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java @@ -25,6 +25,7 @@ import com.iflytek.skillhub.dto.IdentityLinkProviderResponse; import jakarta.servlet.http.HttpSession; import java.util.UUID; import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; /** * Application orchestration for explicit link/unlink workflows. Protocol I/O @@ -182,12 +183,14 @@ public class IdentityLinkAppService { session, intentId, context); + IdentityProviderLoginMethodType browserMethod = + requireBrowserMethod(providerCode); IdentityLinkIntent intent = intentService.prepareExternalReauthentication( - actor, - intentId, - providerCode, - IdentityProviderLoginMethodType.OAUTH_REDIRECT); + actor, + intentId, + providerCode, + browserMethod); sessionManager.prepareBrowserFlow( session, intentId, @@ -196,6 +199,7 @@ public class IdentityLinkAppService { context); return browserAuthorizationUrl( providerCode, + browserMethod, "/settings/security?identityLink=reauthenticated" + "&intentId=" + intentId); @@ -209,10 +213,15 @@ public class IdentityLinkAppService { session, intentId, context); + IdentityLinkIntent current = intentService.getIntent( + actor, + intentId); + IdentityProviderLoginMethodType browserMethod = + requireBrowserMethod(current.providerCode()); IdentityLinkIntent intent = intentService.prepareExternalLink( actor, intentId, - IdentityProviderLoginMethodType.OAUTH_REDIRECT); + browserMethod); sessionManager.prepareBrowserFlow( session, intentId, @@ -221,6 +230,7 @@ public class IdentityLinkAppService { context); return browserAuthorizationUrl( intent.providerCode(), + browserMethod, "/settings/security?identityLink=linked" + "&intentId=" + intentId); @@ -326,13 +336,52 @@ public class IdentityLinkAppService { private String browserAuthorizationUrl( String providerCode, + IdentityProviderLoginMethodType methodType, String returnTo) { - return "/oauth2/authorization/" - + providerCode - + "?returnTo=" - + java.net.URLEncoder.encode( - returnTo, - java.nio.charset.StandardCharsets.UTF_8); + if (methodType + == IdentityProviderLoginMethodType.OAUTH_REDIRECT) { + return "/oauth2/authorization/" + + providerCode + + "?returnTo=" + + java.net.URLEncoder.encode( + returnTo, + java.nio.charset.StandardCharsets.UTF_8); + } + if (methodType + == IdentityProviderLoginMethodType.CAS_REDIRECT) { + return UriComponentsBuilder + .fromPath( + "/api/v1/auth/cas/{providerCode}/login") + .queryParam("returnTo", returnTo) + .buildAndExpand(providerCode) + .encode() + .toUriString(); + } + throw new IdentityLinkException( + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + + private IdentityProviderLoginMethodType requireBrowserMethod( + String providerCode) { + boolean casAvailable = false; + for (var method : providerRegistry.listReadyLoginMethods()) { + if (!method.providerCode().equals(providerCode)) { + continue; + } + if (method.methodType() + == IdentityProviderLoginMethodType.OAUTH_REDIRECT) { + return IdentityProviderLoginMethodType.OAUTH_REDIRECT; + } + if (method.methodType() + == IdentityProviderLoginMethodType.CAS_REDIRECT) { + casAvailable = true; + } + } + if (casAvailable) { + return IdentityProviderLoginMethodType.CAS_REDIRECT; + } + throw new IdentityLinkException( + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); } private IdentityLinkIntentResponse toResponse( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java index 105840a6..86e0dc5e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java @@ -68,4 +68,30 @@ class CasLoginControllerTest { .doesNotContain("ST-secret") .doesNotContain("state-secret"); } + + @Test + void callbackMapsReplayToDedicatedCredentialFreeReason() { + MockHttpServletRequest request = + new MockHttpServletRequest(); + when(loginAppService.complete( + "cas-main", + "ST-replayed", + "state-replayed", + request)).thenThrow( + new CasLoginFlowException( + CasLoginFailure.REPLAY_DETECTED)); + + var response = controller.callback( + "cas-main", + "ST-replayed", + "state-replayed", + request); + + assertThat(response.getHeaders().getLocation()) + .hasToString( + "/login?reason=casReplayDetected"); + assertThat(response.getHeaders().getLocation().toString()) + .doesNotContain("ST-replayed") + .doesNotContain("state-replayed"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index 11ec0aec..3a9c2942 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -6,6 +6,7 @@ import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.ServletResponse; @@ -38,7 +39,7 @@ class RequestLoggingFilterTest { @Test void doFilterInternal_truncatesLongRequestBodyAndOmitsResponseBody() throws ServletException, IOException { - RequestLoggingFilter filter = new RequestLoggingFilter(); + RequestLoggingFilter filter = filter(); String longBody = "x".repeat(5_000); attachAppender(); @@ -69,7 +70,7 @@ class RequestLoggingFilterTest { @Test void doFilterInternal_skipsActuatorEndpoints() throws ServletException, IOException { - RequestLoggingFilter filter = new RequestLoggingFilter(); + RequestLoggingFilter filter = filter(); attachAppender(); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/actuator/health"); @@ -85,7 +86,7 @@ class RequestLoggingFilterTest { @Test void doFilterInternal_skipsOtherSseEndpointsWithoutWrappingResponse() throws ServletException, IOException { - RequestLoggingFilter filter = new RequestLoggingFilter(); + RequestLoggingFilter filter = filter(); attachAppender(); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/scan/sse"); @@ -110,7 +111,7 @@ class RequestLoggingFilterTest { @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { - RequestLoggingFilter filter = new RequestLoggingFilter(); + RequestLoggingFilter filter = filter(); attachAppender(); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/skills"); @@ -132,7 +133,7 @@ class RequestLoggingFilterTest { @Test void doFilterInternal_shouldBypassCachingWrapperForNotificationSse() throws Exception { - RequestLoggingFilter filter = new RequestLoggingFilter(); + RequestLoggingFilter filter = filter(); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicReference responseSeenByChain = new AtomicReference<>(); @@ -153,7 +154,7 @@ class RequestLoggingFilterTest { @Test void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception { - RequestLoggingFilter filter = new RequestLoggingFilter(); + RequestLoggingFilter filter = filter(); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/unread-count"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicReference responseSeenByChain = new AtomicReference<>(); @@ -168,6 +169,46 @@ class RequestLoggingFilterTest { assertThat(response.getContentAsString()).isEqualTo("{\"count\":1}"); } + @Test + void doFilterInternal_redactsCasQueryAndOmitsAuthBody() + throws Exception { + RequestLoggingFilter filter = filter(); + attachAppender(); + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + "/api/v1/auth/cas/cas-main/callback"); + request.setQueryString( + "ticket=ST-request-log-secret&state=state-secret" + + "&returnTo=%2Fdashboard"); + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setContentType(MediaType.APPLICATION_JSON_VALUE); + request.setContent( + "{\"password\":\"body-secret\"}" + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (req, res) -> req.getReader().lines().count()); + + assertThat(loggedMessages()).anySatisfy(message -> { + assertThat(message) + .contains("ticket=[REDACTED]") + .contains("state=[REDACTED]") + .contains("returnTo=%2Fdashboard") + .doesNotContain("ST-request-log-secret") + .doesNotContain("state-secret") + .doesNotContain("body-secret"); + }); + } + + private RequestLoggingFilter filter() { + return new RequestLoggingFilter( + new SensitiveLogSanitizer()); + } + private void attachAppender() { logger.setLevel(Level.INFO); appender = new ListAppender<>(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java index f68e819e..522ec465 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java @@ -10,11 +10,34 @@ class SensitiveLogSanitizerTest { @Test void shouldRedactSensitiveQueryParameters() { - String sanitized = sanitizer.sanitizeQuery("returnTo=%2Fdashboard&token=abc123&password=secret&code=xyz"); + String sanitized = sanitizer.sanitizeQuery( + "returnTo=%2Fdashboard&token=abc123&password=secret" + + "&code=xyz&ticket=ST-secret&state=state-secret"); assertThat(sanitized).contains("returnTo=%2Fdashboard"); assertThat(sanitized).contains("token=[REDACTED]"); assertThat(sanitized).contains("password=[REDACTED]"); assertThat(sanitized).contains("code=[REDACTED]"); + assertThat(sanitized).contains("ticket=[REDACTED]"); + assertThat(sanitized).contains("state=[REDACTED]"); + assertThat(sanitized) + .doesNotContain("ST-secret") + .doesNotContain("state-secret"); + } + + @Test + void shouldRedactEncodedAndMalformedSensitiveKeys() { + String sanitized = sanitizer.sanitizeQuery( + "ti%63ket=ST-encoded-secret" + + "&st%61te=encoded-state-secret" + + "&bad%=unknown-secret"); + + assertThat(sanitized) + .contains("ti%63ket=[REDACTED]") + .contains("st%61te=[REDACTED]") + .contains("bad%=[REDACTED]") + .doesNotContain("ST-encoded-secret") + .doesNotContain("encoded-state-secret") + .doesNotContain("unknown-secret"); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java index c5c3090d..d7ff41a0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -14,6 +15,14 @@ import static org.mockito.Mockito.when; import com.iflytek.skillhub.auth.cas.CasAuthenticationExchange; import com.iflytek.skillhub.auth.cas.CasBrowserClient; import com.iflytek.skillhub.auth.cas.CasLoginInitiation; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService; +import com.iflytek.skillhub.auth.identity.IdentityLinkActor; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; @@ -23,6 +32,7 @@ import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import java.net.URI; import java.time.Duration; import java.time.Instant; @@ -58,7 +68,7 @@ class CasLoginAppServiceTest { PlatformPrincipal principal = principal(); when(fixture.stateStore.consume( request.getSession().getId(), - STATE)).thenReturn(Optional.of(loginState())); + STATE)).thenReturn(consumedState()); when(fixture.protocolClient.validate( PROVIDER, "ST-1", @@ -68,8 +78,8 @@ class CasLoginAppServiceTest { .thenReturn(result); when(fixture.providerLogin.authenticate( isNull(), - org.mockito.ArgumentMatchers.eq(result), - org.mockito.ArgumentMatchers.eq(request))) + eq(result), + eq(request))) .thenReturn(principal); assertThat(fixture.service.complete( @@ -97,9 +107,9 @@ class CasLoginAppServiceTest { SERVICE); order.verify(fixture.adapter).authenticate(exchange); order.verify(fixture.providerLogin).authenticate( - isNull(), - org.mockito.ArgumentMatchers.eq(result), - org.mockito.ArgumentMatchers.eq(request)); + null, + result, + request); order.verify(fixture.sessions) .establishSession(principal, request); } @@ -138,6 +148,11 @@ class CasLoginAppServiceTest { SERVICE, "/dashboard", Duration.ofMinutes(5)); + verify(fixture.identityLinkSessionManager) + .activateBrowserFlow( + request.getSession(), + PROVIDER, + STATE); } @Test @@ -146,7 +161,9 @@ class CasLoginAppServiceTest { MockHttpServletRequest request = request(); when(fixture.stateStore.consume( request.getSession().getId(), - STATE)).thenReturn(Optional.empty()); + STATE)).thenReturn(new CasLoginStateStore.ConsumeResult( + CasLoginStateStore.ConsumeStatus.NOT_FOUND, + null)); assertThatThrownBy(() -> fixture.service.complete( PROVIDER, @@ -169,7 +186,7 @@ class CasLoginAppServiceTest { MockHttpServletRequest request = request(); when(fixture.stateStore.consume( request.getSession().getId(), - STATE)).thenReturn(Optional.of(loginState())); + STATE)).thenReturn(consumedState()); assertThatThrownBy(() -> fixture.service.complete( PROVIDER, @@ -198,7 +215,7 @@ class CasLoginAppServiceTest { MockHttpServletRequest request = request(); when(fixture.stateStore.consume( request.getSession().getId(), - STATE)).thenReturn(Optional.of(loginState())); + STATE)).thenReturn(consumedState()); when(fixture.protocolClient.validate( PROVIDER, "ST-1", @@ -221,6 +238,145 @@ class CasLoginAppServiceTest { fixture.sessions); } + @Test + void classifiesAndAuditsReplayedStateWithoutCallingProvider() { + Fixture fixture = new Fixture(); + MockHttpServletRequest request = request(); + when(fixture.stateStore.consume( + request.getSession().getId(), + STATE)).thenReturn(new CasLoginStateStore.ConsumeResult( + CasLoginStateStore.ConsumeStatus.REPLAYED, + null)); + + assertThatThrownBy(() -> fixture.service.complete( + PROVIDER, + "ST-replayed", + STATE, + request)) + .isInstanceOf(CasLoginFlowException.class) + .extracting("failure") + .isEqualTo(CasLoginFailure.REPLAY_DETECTED); + + verify(fixture.auditLogService).record( + null, + "IDENTITY_REPLAY_DETECTED", + "IDENTITY_PROVIDER", + null, + null, + "127.0.0.1", + null, + "{\"providerCode\":\"cas-main\"," + + "\"protocol\":\"cas\"," + + "\"reason\":\"REPLAY_DETECTED\"," + + "\"artifact\":\"state\"}"); + verifyNoInteractions( + fixture.protocolClient, + fixture.adapter, + fixture.providerLogin, + fixture.sessions); + } + + @Test + void completesCasReauthenticationThroughIdentityLinkCore() { + Fixture fixture = new Fixture(); + MockHttpServletRequest request = request(); + CasAuthenticationExchange exchange = + new CasAuthenticationExchange( + "user-1", + Map.of(), + Instant.parse("2026-07-31T00:00:00Z")); + ProviderAuthenticationResult result = result(); + IdentityLinkBrowserFlow flow = linkFlow( + IdentityLinkBrowserPhase.REAUTHENTICATE); + when(fixture.stateStore.consume( + request.getSession().getId(), + STATE)).thenReturn(consumedState()); + when(fixture.identityLinkSessionManager.consumeBrowserFlow( + eq(request), + eq(PROVIDER), + any(IdentityLoginContext.class))) + .thenReturn(Optional.of(flow)); + when(fixture.protocolClient.validate( + PROVIDER, + "ST-link", + SERVICE)).thenReturn(exchange); + when(fixture.route.adapter()).thenReturn(fixture.adapter); + when(fixture.adapter.authenticate(exchange)) + .thenReturn(result); + when(fixture.externalIdentityLinkService.reauthenticate( + flow.actor(), + flow.intentId(), + null, + result)).thenReturn( + new IdentityLinkOutcome.Reauthenticated( + principal())); + + assertThat(fixture.service.complete( + PROVIDER, + "ST-link", + STATE, + request)).isEqualTo("/skills"); + + verify(fixture.externalIdentityLinkService).reauthenticate( + flow.actor(), + flow.intentId(), + null, + result); + verifyNoInteractions( + fixture.providerLogin, + fixture.sessions); + verify(fixture.identityLinkSessionManager, never()) + .remove(any(), any()); + } + + @Test + void mapsCasIdentityLinkProviderFailureToResumableRedirect() { + Fixture fixture = new Fixture(); + MockHttpServletRequest request = request(); + IdentityLinkBrowserFlow flow = linkFlow( + IdentityLinkBrowserPhase.LINK); + when(fixture.stateStore.consume( + request.getSession().getId(), + STATE)).thenReturn(consumedState()); + when(fixture.identityLinkSessionManager.consumeBrowserFlow( + eq(request), + eq(PROVIDER), + any(IdentityLoginContext.class))) + .thenReturn(Optional.of(flow)); + when(fixture.protocolClient.validate( + PROVIDER, + "ST-unavailable", + SERVICE)).thenThrow( + new ProviderAuthenticationException( + ProviderAuthenticationFailureCode + .UPSTREAM_UNAVAILABLE)); + + assertThat(fixture.service.complete( + PROVIDER, + "ST-unavailable", + STATE, + request)).isEqualTo( + "/settings/security?identityLink=failed" + + "&intentId=" + + flow.intentId() + + "&reasonCode=" + + IdentityLinkFailureCode + .PROVIDER_UNAVAILABLE); + + verifyNoInteractions( + fixture.adapter, + fixture.externalIdentityLinkService, + fixture.providerLogin, + fixture.sessions); + } + + private static CasLoginStateStore.ConsumeResult + consumedState() { + return new CasLoginStateStore.ConsumeResult( + CasLoginStateStore.ConsumeStatus.CONSUMED, + loginState()); + } + private static CasLoginStateStore.CasLoginState loginState() { return new CasLoginStateStore.CasLoginState( PROVIDER, @@ -229,6 +385,22 @@ class CasLoginAppServiceTest { Instant.parse("2026-07-31T00:05:00Z")); } + private static IdentityLinkBrowserFlow linkFlow( + IdentityLinkBrowserPhase phase) { + return new IdentityLinkBrowserFlow( + java.util.UUID.fromString( + "2302dcb8-0cb3-4da7-a587-b85645ecb834"), + phase, + new IdentityLinkActor( + "usr_1", + "local", + "session-nonce", + new IdentityLoginContext( + "req-1", + "127.0.0.1", + "JUnit"))); + } + private static ProviderAuthenticationResult result() { return new ProviderAuthenticationResult( new SubjectCandidate( @@ -267,10 +439,18 @@ class CasLoginAppServiceTest { mock(CasBrowserClient.class); private final ProviderLoginAppService providerLogin = mock(ProviderLoginAppService.class); + private final ExternalIdentityLinkService + externalIdentityLinkService = + mock(ExternalIdentityLinkService.class); + private final IdentityLinkSessionManager + identityLinkSessionManager = + mock(IdentityLinkSessionManager.class); private final PlatformSessionService sessions = mock(PlatformSessionService.class); private final CasLoginStateStore stateStore = mock(CasLoginStateStore.class); + private final AuditLogService auditLogService = + mock(AuditLogService.class); @SuppressWarnings("unchecked") private final IdentityProviderRegistry.BrowserRoute route = @@ -284,8 +464,11 @@ class CasLoginAppServiceTest { registry, protocolClient, providerLogin, + externalIdentityLinkService, + identityLinkSessionManager, sessions, stateStore, + auditLogService, () -> STATE); private Fixture() { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java index 2aae5aca..70c5d5f9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java @@ -2,7 +2,9 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -12,10 +14,12 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; class CasLoginStateStoreTest { @@ -64,20 +68,32 @@ class CasLoginStateStoreTest { .startsWith("skillhub:auth:cas:state:") .doesNotContain(SESSION_ID) .doesNotContain(STATE); + assertThat(serialized.getValue()).startsWith("P:"); - when(values.getAndDelete(key.getValue())) + when(redis.execute( + org.mockito.ArgumentMatchers + .>any(), + eq(List.of(key.getValue())))) .thenReturn(serialized.getValue()) + .thenReturn("R") .thenReturn((String) null); - assertThat(store.consume(SESSION_ID, STATE)) - .get() + CasLoginStateStore.ConsumeResult consumed = + store.consume(SESSION_ID, STATE); + assertThat(consumed.status()) + .isEqualTo( + CasLoginStateStore.ConsumeStatus.CONSUMED); + assertThat(consumed.state()) .extracting( CasLoginStateStore.CasLoginState::providerCode, CasLoginStateStore.CasLoginState::returnTo) .containsExactly("cas-main", "/dashboard"); - assertThat(store.consume(SESSION_ID, STATE)).isEmpty(); - verify(values, org.mockito.Mockito.times(2)) - .getAndDelete(key.getValue()); + assertThat(store.consume(SESSION_ID, STATE).status()) + .isEqualTo( + CasLoginStateStore.ConsumeStatus.REPLAYED); + assertThat(store.consume(SESSION_ID, STATE).status()) + .isEqualTo( + CasLoginStateStore.ConsumeStatus.NOT_FOUND); } @Test @@ -100,11 +116,20 @@ class CasLoginStateStoreTest { "https://skill.example/callback", null, NOW.minusSeconds(1))); - when(values.getAndDelete(anyString())) - .thenReturn(expired); + when(redis.execute( + org.mockito.ArgumentMatchers + .>any(), + anyList())) + .thenReturn("P:" + expired); - assertThat(store.consume(SESSION_ID, STATE)).isEmpty(); - assertThat(store.consume(SESSION_ID, "not valid")).isEmpty(); + assertThat(store.consume(SESSION_ID, STATE).status()) + .isEqualTo( + CasLoginStateStore.ConsumeStatus.NOT_FOUND); + assertThat(store.consume( + SESSION_ID, + "not valid").status()) + .isEqualTo( + CasLoginStateStore.ConsumeStatus.NOT_FOUND); } @Test @@ -139,7 +164,10 @@ class CasLoginStateStoreTest { CasLoginStateStore .CasLoginStateStoreException.class); - when(values.getAndDelete(anyString())) + when(redis.execute( + org.mockito.ArgumentMatchers + .>any(), + anyList())) .thenThrow(new IllegalStateException( "redis unavailable")); assertThatThrownBy( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java new file mode 100644 index 00000000..42f831f0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java @@ -0,0 +1,198 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityLinkOperation; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService; +import com.iflytek.skillhub.auth.identity.IdentityLinkActor; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase; +import com.iflytek.skillhub.auth.identity.IdentityLinkException; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkIntent; +import com.iflytek.skillhub.auth.identity.IdentityLinkIntentService; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType; +import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry; +import jakarta.servlet.http.HttpSession; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class IdentityLinkAppServiceTest { + + private static final UUID INTENT_ID = UUID.fromString( + "b731a62c-c168-4d58-8fcb-2f2461748d04"); + private static final String PROVIDER = "cas-main"; + + @Test + void preparesCasReauthenticationWithTheSharedBrowserIntent() { + Fixture fixture = new Fixture(); + IdentityLinkIntent pending = intent( + IdentityLinkRequestStatus.PENDING_REAUTHENTICATION); + when(fixture.registry.listReadyLoginMethods()) + .thenReturn(List.of(casMethod())); + when(fixture.intentService.prepareExternalReauthentication( + fixture.actor, + INTENT_ID, + PROVIDER, + IdentityProviderLoginMethodType.CAS_REDIRECT)) + .thenReturn(pending); + + String actionUrl = + fixture.service.prepareBrowserReauthentication( + INTENT_ID, + PROVIDER, + fixture.session, + fixture.context); + assertCasAction( + actionUrl, + "/settings/security?identityLink=reauthenticated" + + "&intentId=" + + INTENT_ID); + + verify(fixture.sessionManager).prepareBrowserFlow( + fixture.session, + INTENT_ID, + IdentityLinkBrowserPhase.REAUTHENTICATE, + PROVIDER, + fixture.context); + } + + @Test + void preparesCasTargetAuthenticationAfterFreshReauthentication() { + Fixture fixture = new Fixture(); + IdentityLinkIntent ready = intent( + IdentityLinkRequestStatus.READY); + when(fixture.registry.listReadyLoginMethods()) + .thenReturn(List.of(casMethod())); + when(fixture.intentService.getIntent( + fixture.actor, + INTENT_ID)).thenReturn(ready); + when(fixture.intentService.prepareExternalLink( + fixture.actor, + INTENT_ID, + IdentityProviderLoginMethodType.CAS_REDIRECT)) + .thenReturn(ready); + + String actionUrl = fixture.service.prepareBrowserLink( + INTENT_ID, + fixture.session, + fixture.context); + assertCasAction( + actionUrl, + "/settings/security?identityLink=linked" + + "&intentId=" + + INTENT_ID); + + verify(fixture.sessionManager).prepareBrowserFlow( + fixture.session, + INTENT_ID, + IdentityLinkBrowserPhase.LINK, + PROVIDER, + fixture.context); + } + + @Test + void browserEndpointRejectsCredentialOnlyProvider() { + Fixture fixture = new Fixture(); + when(fixture.registry.listReadyLoginMethods()) + .thenReturn(List.of(new IdentityProviderLoginMethod( + PROVIDER, + "Corporate Directory", + IdentityProviderLoginMethodType + .DIRECT_PASSWORD))); + + assertThatThrownBy(() -> + fixture.service.prepareBrowserReauthentication( + INTENT_ID, + PROVIDER, + fixture.session, + fixture.context)) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .PROVIDER_UNAVAILABLE)); + } + + private static IdentityProviderLoginMethod casMethod() { + return new IdentityProviderLoginMethod( + PROVIDER, + "Corporate CAS", + IdentityProviderLoginMethodType.CAS_REDIRECT); + } + + private static void assertCasAction( + String actionUrl, + String expectedReturnTo) { + URI uri = URI.create(actionUrl); + assertThat(uri.getPath()).isEqualTo( + "/api/v1/auth/cas/cas-main/login"); + assertThat(uri.getRawQuery()).startsWith("returnTo="); + assertThat(URLDecoder.decode( + uri.getRawQuery().substring("returnTo=".length()), + StandardCharsets.UTF_8)) + .isEqualTo(expectedReturnTo); + } + + private static IdentityLinkIntent intent( + IdentityLinkRequestStatus status) { + return new IdentityLinkIntent( + INTENT_ID, + IdentityLinkOperation.LINK, + status, + PROVIDER, + null, + Instant.parse("2026-07-31T08:10:00Z")); + } + + private static final class Fixture { + + private final IdentityLinkIntentService intentService = + mock(IdentityLinkIntentService.class); + private final ExternalIdentityLinkService externalLinkService = + mock(ExternalIdentityLinkService.class); + private final IdentityProviderRegistry registry = + mock(IdentityProviderRegistry.class); + private final IdentityLinkSessionManager sessionManager = + mock(IdentityLinkSessionManager.class); + private final HttpSession session = mock(HttpSession.class); + private final IdentityLoginContext context = + new IdentityLoginContext( + "req-1", + "127.0.0.1", + "JUnit"); + private final IdentityLinkActor actor = + new IdentityLinkActor( + "usr_1", + "local", + "session-nonce", + context); + private final IdentityLinkAppService service = + new IdentityLinkAppService( + intentService, + externalLinkService, + registry, + sessionManager); + + private Fixture() { + when(sessionManager.actor( + session, + INTENT_ID, + context)).thenReturn(actor); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java index 4163da2c..374721e7 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java @@ -5,15 +5,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; -import java.io.IOException; -import java.io.InputStream; +import java.io.ByteArrayOutputStream; import java.io.StringReader; -import java.net.ConnectException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.net.http.HttpTimeoutException; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; @@ -21,6 +19,12 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; import javax.net.ssl.SSLException; import javax.xml.XMLConstants; @@ -598,33 +602,36 @@ public final class CasProtocolClient implements CasBrowserClient { .header("Accept", "application/json, application/xml") .GET() .build(); + CompletableFuture> exchange = + client.sendAsync( + request, + responseInfo -> + new LimitedBodySubscriber( + maximumResponseBytes)); try { - HttpResponse response = client.send( - request, - HttpResponse.BodyHandlers.ofInputStream()); - try (InputStream body = response.body()) { - byte[] bytes = body.readNBytes( - maximumResponseBytes + 1); - if (bytes.length > maximumResponseBytes) { - throw new CasTransportFailure( - ProviderAuthenticationFailureCode - .UPSTREAM_INVALID_RESPONSE); - } - return new CasValidationResponse( - response.statusCode(), - new String( - bytes, - StandardCharsets.UTF_8)); - } - } catch (CasTransportFailure failure) { - throw failure; - } catch (HttpTimeoutException - | ConnectException exception) { + HttpResponse response = exchange.get( + readTimeout.toNanos(), + TimeUnit.NANOSECONDS); + return new CasValidationResponse( + response.statusCode(), + new String( + response.body(), + StandardCharsets.UTF_8)); + } catch (TimeoutException exception) { + exchange.cancel(true); throw new CasTransportFailure( ProviderAuthenticationFailureCode .UPSTREAM_UNAVAILABLE); - } catch (IOException exception) { - if (hasTlsCause(exception)) { + } catch (ExecutionException exception) { + Throwable cause = exception.getCause(); + if (hasCause( + cause, + ResponseTooLargeException.class)) { + throw new CasTransportFailure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE); + } + if (hasTlsCause(cause)) { throw new CasTransportFailure( ProviderAuthenticationFailureCode .TLS_VALIDATION_FAILED); @@ -641,15 +648,94 @@ public final class CasProtocolClient implements CasBrowserClient { } private static boolean hasTlsCause(Throwable failure) { + return hasCause(failure, SSLException.class); + } + + private static boolean hasCause( + Throwable failure, + Class type) { for (Throwable current = failure; current != null; current = current.getCause()) { - if (current instanceof SSLException) { + if (type.isInstance(current)) { return true; } } return false; } + + private static final class LimitedBodySubscriber + implements HttpResponse.BodySubscriber { + + private final int maximumResponseBytes; + private final ByteArrayOutputStream output = + new ByteArrayOutputStream(); + private final CompletableFuture body = + new CompletableFuture<>(); + private Flow.Subscription subscription; + + private LimitedBodySubscriber( + int maximumResponseBytes) { + this.maximumResponseBytes = + maximumResponseBytes; + } + + @Override + public CompletionStage getBody() { + return body; + } + + @Override + public void onSubscribe( + Flow.Subscription nextSubscription) { + if (subscription != null) { + nextSubscription.cancel(); + return; + } + subscription = nextSubscription; + nextSubscription.request(1); + } + + @Override + public void onNext(List buffers) { + if (body.isDone()) { + return; + } + for (ByteBuffer buffer : buffers) { + long nextSize = (long) output.size() + + buffer.remaining(); + if (nextSize > maximumResponseBytes) { + subscription.cancel(); + body.completeExceptionally( + new ResponseTooLargeException()); + return; + } + byte[] chunk = + new byte[buffer.remaining()]; + buffer.get(chunk); + output.writeBytes(chunk); + } + subscription.request(1); + } + + @Override + public void onError(Throwable failure) { + body.completeExceptionally(failure); + } + + @Override + public void onComplete() { + body.complete(output.toByteArray()); + } + } + + private static final class ResponseTooLargeException + extends RuntimeException { + + private ResponseTooLargeException() { + super("CAS_RESPONSE_TOO_LARGE"); + } + } } private static final class StrictXmlErrorHandler diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java index d276449a..1d052940 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java @@ -19,8 +19,9 @@ import org.springframework.stereotype.Component; /** * Owns the raw, high-entropy session state used by Identity Link workflows. * - *

Only a SHA-256 digest is stored outside the session. Raw nonces and OAuth - * state values are never returned by API DTOs or written to the database. + *

Only a SHA-256 digest is stored outside the session. Raw nonces and + * browser-protocol state values are never returned by API DTOs or written to + * the database. */ @Component public class IdentityLinkSessionManager { @@ -132,14 +133,14 @@ public class IdentityLinkSessionManager { } /** - * Binds a prepared link flow to the OAuth authorization request generated - * by Spring Security. The raw OAuth state remains in Spring Security's - * authorization request repository; only its digest is copied here. + * Binds a prepared link flow to the browser protocol's authorization + * request. OAuth keeps the raw state in Spring Security and CAS keeps it + * in the CAS state store; only its digest is copied here. */ public void activateBrowserFlow( HttpSession session, String providerCode, - String oauthState) { + String browserState) { if (session == null) { return; } @@ -149,8 +150,8 @@ public class IdentityLinkSessionManager { if (!(value instanceof PendingBrowserFlow pending) || pending.expiresAt().isBefore(now()) || !pending.providerCode().equals(providerCode) - || oauthState == null - || oauthState.isBlank()) { + || browserState == null + || browserState.isBlank()) { return; } session.setAttribute( @@ -159,7 +160,7 @@ public class IdentityLinkSessionManager { pending.intentId(), pending.phase(), pending.providerCode(), - stateHasher.hash(oauthState), + stateHasher.hash(browserState), pending.expiresAt())); } @@ -182,7 +183,7 @@ public class IdentityLinkSessionManager { || !active.providerCode().equals(providerCode) || !stateHasher.matches( callbackState, - active.oauthStateHash())) { + active.browserStateHash())) { throw new IdentityLinkException( IdentityLinkFailureCode.SESSION_MISMATCH); } @@ -277,7 +278,7 @@ public class IdentityLinkSessionManager { UUID intentId, IdentityLinkBrowserPhase phase, String providerCode, - String oauthStateHash, + String browserStateHash, Instant expiresAt ) implements Serializable { @Serial diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java index 32e74a23..fee03fc1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java @@ -496,12 +496,10 @@ class IdentityLinkTransaction { !linkedProviderCodes.contains( provider.providerCode())) .filter(provider -> - provider.methodTypes().contains( - IdentityProviderLoginMethodType - .OAUTH_REDIRECT) - || provider.methodTypes().contains( - IdentityProviderLoginMethodType - .DIRECT_PASSWORD)) + provider.methodTypes().stream() + .anyMatch( + IdentityLinkTransaction + ::isLinkableMethod)) .sorted(Comparator.comparing( ReadyProvider::providerCode)) .map(provider -> @@ -655,15 +653,25 @@ class IdentityLinkTransaction { } ReadyProvider provider = readyProviders().get(providerCode); if (provider == null - || (provider.methodTypes().stream().noneMatch(type -> - type == IdentityProviderLoginMethodType.OAUTH_REDIRECT - || type == IdentityProviderLoginMethodType - .DIRECT_PASSWORD))) { + || provider.methodTypes().stream() + .noneMatch( + IdentityLinkTransaction + ::isLinkableMethod)) { throw failure( IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); } } + private static boolean isLinkableMethod( + IdentityProviderLoginMethodType methodType) { + return methodType + == IdentityProviderLoginMethodType.OAUTH_REDIRECT + || methodType + == IdentityProviderLoginMethodType.CAS_REDIRECT + || methodType + == IdentityProviderLoginMethodType.DIRECT_PASSWORD; + } + private void requireProviderCapability( IdentityLinkActor actor, IdentityLinkRequest request, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 8beb7a3c..28fbf4f8 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -25,7 +25,12 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/me"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/session/bootstrap"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/auth/cas/**"), + RouteAuthorizationPolicy.permitAll( + HttpMethod.GET, + "/api/v1/auth/cas/*/login"), + RouteAuthorizationPolicy.permitAll( + HttpMethod.GET, + "/api/v1/auth/cas/*/callback"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java index 99fc5be1..cf329652 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java @@ -13,6 +13,7 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; @@ -151,7 +152,48 @@ class CasProtocolClientHttpIntegrationTest { .UPSTREAM_INVALID_RESPONSE); } + @Test + void requestTimeoutIncludesSlowResponseBodyConsumption() { + server.createContext( + "/cas/p3/serviceValidate", + exchange -> { + exchange.sendResponseHeaders(200, 0); + try (var body = exchange.getResponseBody()) { + body.write('{'); + body.flush(); + try { + Thread.sleep(500); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return; + } + body.write('}'); + } + }); + + CasProtocolClient client = client( + 1024, + Duration.ofMillis(100)); + + assertThatThrownBy(() -> client.validate( + "cas-main", + "ST-slow-body", + client.begin("cas-main", STATE).serviceUrl())) + .isInstanceOf(ProviderAuthenticationException.class) + .extracting("reasonCode") + .isEqualTo(ProviderAuthenticationFailureCode + .UPSTREAM_UNAVAILABLE); + } + private CasProtocolClient client(int maximumResponseBytes) { + return client( + maximumResponseBytes, + Duration.ofSeconds(10)); + } + + private CasProtocolClient client( + int maximumResponseBytes, + Duration readTimeout) { CasProperties properties = CasTestConfiguration.validProperties(); properties.setAllowInsecureForTesting(true); @@ -162,6 +204,7 @@ class CasProtocolClientHttpIntegrationTest { properties.setServiceUrl( "http://skillhub.test/api/v1/auth/cas/cas-main/callback"); properties.setMaxResponseBytes(maximumResponseBytes); + properties.setReadTimeout(readTimeout); MockEnvironment environment = new MockEnvironment(); environment.setActiveProfiles("test"); return new CasProtocolClient( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java new file mode 100644 index 00000000..3be46271 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java @@ -0,0 +1,137 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBindingStatus; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequest; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository; +import com.iflytek.skillhub.auth.repository.IdentityLinkRequestRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class IdentityLinkTransactionCasCapabilityTest { + + private final IdentityLinkRequestRepository requestRepository = + mock(IdentityLinkRequestRepository.class); + private final IdentityBindingRepository bindingRepository = + mock(IdentityBindingRepository.class); + private final IdentityBindingSubjectRepository subjectRepository = + mock(IdentityBindingSubjectRepository.class); + private final LocalCredentialRepository credentialRepository = + mock(LocalCredentialRepository.class); + private final UserAccountRepository userRepository = + mock(UserAccountRepository.class); + private final IdentityProviderRegistry providerRegistry = + mock(IdentityProviderRegistry.class); + private final IdentityLinkStateHasher stateHasher = + new IdentityLinkStateHasher(); + private final AccountLoginGuard accountLoginGuard = + mock(AccountLoginGuard.class); + private final PlatformPrincipalFactory principalFactory = + mock(PlatformPrincipalFactory.class); + private final AuditLogService auditLogService = + mock(AuditLogService.class); + private final UserAccount user = mock(UserAccount.class); + private final IdentityLinkTransaction transaction = + new IdentityLinkTransaction( + requestRepository, + bindingRepository, + subjectRepository, + credentialRepository, + userRepository, + providerRegistry, + stateHasher, + accountLoginGuard, + principalFactory, + auditLogService, + Clock.fixed( + Instant.parse("2026-07-31T08:00:00Z"), + ZoneOffset.UTC)); + + @BeforeEach + void configureReadyCasProvider() { + when(providerRegistry.listReadyLoginMethods()) + .thenReturn(List.of(new IdentityProviderLoginMethod( + "cas-main", + "Corporate CAS", + IdentityProviderLoginMethodType.CAS_REDIRECT))); + when(accountLoginGuard.evaluateInteractive(user)) + .thenReturn(AccountLoginDecision.ALLOWED); + } + + @Test + void exposesReadyCasAsAnAvailableIdentityLinkProvider() { + when(userRepository.findById("usr_1")) + .thenReturn(Optional.of(user)); + when(bindingRepository.findByUserIdAndStatus( + "usr_1", + IdentityBindingStatus.ACTIVE)) + .thenReturn(List.of()); + when(credentialRepository.existsByUserId("usr_1")) + .thenReturn(true); + + IdentityLinkAccountState state = + transaction.accountState("usr_1"); + + assertThat(state.availableProviders()) + .containsExactly(new IdentityLinkProviderView( + "cas-main", + "Corporate CAS", + Set.of( + IdentityProviderLoginMethodType + .CAS_REDIRECT))); + } + + @Test + void createsLinkIntentForReadyCasProvider() { + UUID intentId = UUID.fromString( + "94a94b82-bdb6-46bf-a1ff-910514519308"); + IdentityLinkActor actor = new IdentityLinkActor( + "usr_1", + "local", + "session-nonce", + new IdentityLoginContext( + "req-1", + "127.0.0.1", + "JUnit")); + when(userRepository.findByIdForUpdate("usr_1")) + .thenReturn(Optional.of(user)); + when(bindingRepository.findByUserIdAndStatus( + "usr_1", + IdentityBindingStatus.ACTIVE)) + .thenReturn(List.of()); + when(requestRepository.findActiveByPrimaryUserIdForUpdate( + any(), + any())).thenReturn(Optional.empty()); + when(requestRepository.saveAndFlush( + any(IdentityLinkRequest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + IdentityLinkIntent intent = transaction.createLinkIntent( + actor, + intentId, + "cas-main"); + + assertThat(intent.id()).isEqualTo(intentId); + assertThat(intent.providerCode()).isEqualTo("cas-main"); + assertThat(intent.status()).isEqualTo( + IdentityLinkRequestStatus + .PENDING_REAUTHENTICATION); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 890e1a63..7df1ae00 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -59,16 +59,30 @@ class RouteSecurityPolicyRegistryTest { } @Test - void authorizationPolicies_shouldAllowCasBrowserFlowAnonymously() { - boolean matched = registry.authorizationPolicies().stream() + void authorizationPolicies_shouldAllowOnlyCasBrowserEndpointsAnonymously() { + boolean loginMatched = registry.authorizationPolicies().stream() .anyMatch(policy -> policy.method() == HttpMethod.GET - && "/api/v1/auth/cas/**".equals( + && "/api/v1/auth/cas/*/login".equals( policy.pattern()) && policy.accessLevel() == RouteSecurityPolicyRegistry .AccessLevel.PERMIT_ALL); + boolean callbackMatched = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/v1/auth/cas/*/callback".equals( + policy.pattern()) + && policy.accessLevel() + == RouteSecurityPolicyRegistry + .AccessLevel.PERMIT_ALL); + boolean broadWildcardPresent = + registry.authorizationPolicies().stream() + .anyMatch(policy -> + "/api/v1/auth/cas/**".equals( + policy.pattern())); - assertTrue(matched); + assertTrue(loginMatched); + assertTrue(callbackMatched); + assertFalse(broadWildcardPresent); } @Test diff --git a/web/nginx.conf.template b/web/nginx.conf.template index 25db2869..70c97eba 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -1,6 +1,12 @@ +log_format skillhub_no_query + '$remote_addr - $remote_user [$time_local] ' + '"$request_method $uri $server_protocol" $status $body_bytes_sent ' + '"-" "$http_user_agent"'; + server { listen 80; server_name _; + access_log /var/log/nginx/access.log skillhub_no_query; root /usr/share/nginx/html; index index.html; diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 22773753..b4316687 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3238,6 +3238,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/cas/{providerCode}/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Start a CAS browser login */ + get: operations["login_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/cas/{providerCode}/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Complete a CAS browser login */ + get: operations["callback"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/users": { parameters: { query?: never; @@ -5145,14 +5179,14 @@ export interface components { bindingId?: number; providerCode?: string; displayName?: string; - methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[]; + methodTypes?: ("OAUTH_REDIRECT" | "CAS_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[]; usable?: boolean; canUnlink?: boolean; }; IdentityLinkProviderResponse: { providerCode?: string; displayName?: string; - methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[]; + methodTypes?: ("OAUTH_REDIRECT" | "CAS_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[]; }; AdminUserSummaryResponse: { id?: string; @@ -11845,6 +11879,51 @@ export interface operations { }; }; }; + login_1: { + parameters: { + query?: { + returnTo?: string; + }; + header?: never; + path: { + providerCode: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the configured CAS login endpoint */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + callback: { + parameters: { + query?: { + ticket?: string; + state?: string; + }; + header?: never; + path: { + providerCode: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the authenticated target or login failure page */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listUsers: { parameters: { query?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 238bb447..578892f2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,9 +15,14 @@ export type OAuthProvider = Omit & { id: string - methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'CAS_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string + methodType: NonNullable provider: string displayName: string actionUrl: string diff --git a/web/src/features/auth/identity-link-manager.test.tsx b/web/src/features/auth/identity-link-manager.test.tsx index 8b66db00..2bfbffb0 100644 --- a/web/src/features/auth/identity-link-manager.test.tsx +++ b/web/src/features/auth/identity-link-manager.test.tsx @@ -163,6 +163,33 @@ describe('IdentityLinkManager', () => { expect(html).toMatch(/]*disabled/) }) + it('treats CAS as a browser identity link method', () => { + accountState = { + localPasswordEnabled: false, + linkedProviders: [{ + bindingId: 43, + providerCode: 'cas-main', + displayName: 'Corporate CAS', + methodTypes: ['CAS_REDIRECT'], + usable: true, + canUnlink: true, + }], + availableProviders: [{ + providerCode: 'cas-backup', + displayName: 'Backup CAS', + methodTypes: ['CAS_REDIRECT'], + }], + } + + const html = renderToStaticMarkup() + + expect(html).toContain('Corporate CAS') + expect(html).toContain('Backup CAS') + expect(html).not.toContain( + 'security.identityLinks.noReauthenticationMethod', + ) + }) + it('shows the browser-link success result after callback', () => { vi.stubGlobal('window', { location: { diff --git a/web/src/features/auth/identity-link-manager.tsx b/web/src/features/auth/identity-link-manager.tsx index ba68c26c..a1f484ed 100644 --- a/web/src/features/auth/identity-link-manager.tsx +++ b/web/src/features/auth/identity-link-manager.tsx @@ -106,11 +106,18 @@ function errorMessage(error: unknown, fallback: string) { function hasMethod( provider: IdentityLinkBinding | IdentityLinkProvider, - method: 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD', + method: 'OAUTH_REDIRECT' | 'CAS_REDIRECT' | 'DIRECT_PASSWORD', ) { return provider.methodTypes.includes(method) } +function hasBrowserMethod( + provider: IdentityLinkBinding | IdentityLinkProvider, +) { + return hasMethod(provider, 'OAUTH_REDIRECT') + || hasMethod(provider, 'CAS_REDIRECT') +} + function CredentialFields({ prefix, value, @@ -413,7 +420,7 @@ export function IdentityLinkManager() { ) : undefined const browserReauthenticationProviders = linkedProviders.filter( - (provider) => provider.usable && hasMethod(provider, 'OAUTH_REDIRECT'), + (provider) => provider.usable && hasBrowserMethod(provider), ) const credentialReauthenticationProviders = linkedProviders.filter( (provider) => provider.usable && hasMethod(provider, 'DIRECT_PASSWORD'), @@ -696,7 +703,7 @@ export function IdentityLinkManager() {

{t('security.identityLinks.reauthenticated')}
- {targetProvider && hasMethod(targetProvider, 'OAUTH_REDIRECT') ? ( + {targetProvider && hasBrowserMethod(targetProvider) ? (