mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
fix(auth): harden CAS identity link flow
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
07c2c9d750
commit
2c1bcf3b22
32 changed files with 1461 additions and 133 deletions
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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 登录。
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<Void> 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<Void> 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 ->
|
||||
|
|
|
|||
|
|
@ -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<String> SKIP_SUFFIXES = Set.of(
|
||||
"/sse"
|
||||
);
|
||||
private static final Set<String> 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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]";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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<String> 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<IdentityLinkBrowserFlow> 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
|
||||
<CasAuthenticationExchange> 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 =
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package com.iflytek.skillhub.service;
|
|||
*/
|
||||
public enum CasLoginFailure {
|
||||
INVALID_STATE,
|
||||
REPLAY_DETECTED,
|
||||
TICKET_MISSING,
|
||||
VALIDATION_FAILED,
|
||||
PROVIDER_UNAVAILABLE,
|
||||
|
|
|
|||
|
|
@ -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<String> 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<CasLoginState> 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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ServletResponse> 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<ServletResponse> 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<>();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<CasAuthenticationExchange> route =
|
||||
|
|
@ -284,8 +464,11 @@ class CasLoginAppServiceTest {
|
|||
registry,
|
||||
protocolClient,
|
||||
providerLogin,
|
||||
externalIdentityLinkService,
|
||||
identityLinkSessionManager,
|
||||
sessions,
|
||||
stateStore,
|
||||
auditLogService,
|
||||
() -> STATE);
|
||||
|
||||
private Fixture() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.<RedisScript<String>>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
|
||||
.<RedisScript<String>>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
|
||||
.<RedisScript<String>>any(),
|
||||
anyList()))
|
||||
.thenThrow(new IllegalStateException(
|
||||
"redis unavailable"));
|
||||
assertThatThrownBy(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HttpResponse<byte[]>> exchange =
|
||||
client.sendAsync(
|
||||
request,
|
||||
responseInfo ->
|
||||
new LimitedBodySubscriber(
|
||||
maximumResponseBytes));
|
||||
try {
|
||||
HttpResponse<InputStream> 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<byte[]> 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<? extends Throwable> 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<byte[]> {
|
||||
|
||||
private final int maximumResponseBytes;
|
||||
private final ByteArrayOutputStream output =
|
||||
new ByteArrayOutputStream();
|
||||
private final CompletableFuture<byte[]> body =
|
||||
new CompletableFuture<>();
|
||||
private Flow.Subscription subscription;
|
||||
|
||||
private LimitedBodySubscriber(
|
||||
int maximumResponseBytes) {
|
||||
this.maximumResponseBytes =
|
||||
maximumResponseBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<byte[]> 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<ByteBuffer> 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
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import org.springframework.stereotype.Component;
|
|||
/**
|
||||
* Owns the raw, high-entropy session state used by Identity Link workflows.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
83
web/src/api/generated/schema.d.ts
vendored
83
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,14 @@ export type OAuthProvider = Omit<components['schemas']['AuthProviderResponse'],
|
|||
authorizationUrl: string
|
||||
}
|
||||
|
||||
export interface AuthMethod {
|
||||
type AuthMethodSchema = components['schemas']['AuthMethodResponse']
|
||||
|
||||
export type AuthMethod = Omit<
|
||||
AuthMethodSchema,
|
||||
'id' | 'methodType' | 'provider' | 'displayName' | 'actionUrl'
|
||||
> & {
|
||||
id: string
|
||||
methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'CAS_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string
|
||||
methodType: NonNullable<AuthMethodSchema['methodType']>
|
||||
provider: string
|
||||
displayName: string
|
||||
actionUrl: string
|
||||
|
|
|
|||
|
|
@ -163,6 +163,33 @@ describe('IdentityLinkManager', () => {
|
|||
expect(html).toMatch(/<button[^>]*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(<IdentityLinkManager />)
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{t('security.identityLinks.reauthenticated')}
|
||||
</div>
|
||||
{targetProvider && hasMethod(targetProvider, 'OAUTH_REDIRECT') ? (
|
||||
{targetProvider && hasBrowserMethod(targetProvider) ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ describe('login failure messages', () => {
|
|||
.toBe('login.failure.casValidationFailed')
|
||||
expect(loginFailureMessageKey('casUnavailable'))
|
||||
.toBe('login.failure.casUnavailable')
|
||||
expect(loginFailureMessageKey('casReplayDetected'))
|
||||
.toBe('login.failure.casReplayDetected')
|
||||
})
|
||||
|
||||
it('does not render untrusted reason text', () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const LOGIN_FAILURE_MESSAGE_KEYS: Record<string, string> = {
|
|||
accountDisabled: 'apiError.auth.accountDisabled',
|
||||
linkRequired: 'login.failure.linkRequired',
|
||||
casInvalidState: 'login.failure.casInvalidState',
|
||||
casReplayDetected: 'login.failure.casReplayDetected',
|
||||
casTicketMissing: 'login.failure.casTicketMissing',
|
||||
casValidationFailed: 'login.failure.casValidationFailed',
|
||||
casUnavailable: 'login.failure.casUnavailable',
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@
|
|||
"failure": {
|
||||
"linkRequired": "This external identity must be linked to an existing account before it can sign in.",
|
||||
"casInvalidState": "This CAS login request has expired or was already used. Start the login again.",
|
||||
"casReplayDetected": "This CAS login request was already used. The replay was rejected; start the login again.",
|
||||
"casTicketMissing": "The CAS server did not return a service ticket. Start the login again.",
|
||||
"casValidationFailed": "CAS could not verify this login. Start the login again or contact your administrator.",
|
||||
"casUnavailable": "CAS login is temporarily unavailable. Try again later or contact your administrator.",
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@
|
|||
"failure": {
|
||||
"linkRequired": "该外部身份需要先关联已有账号,暂时不能直接登录。",
|
||||
"casInvalidState": "本次 CAS 登录请求已过期或已被使用,请重新发起登录。",
|
||||
"casReplayDetected": "本次 CAS 登录请求已被使用,系统已拒绝重复请求,请重新发起登录。",
|
||||
"casTicketMissing": "CAS 服务没有返回服务票据,请重新发起登录。",
|
||||
"casValidationFailed": "CAS 未能验证本次登录,请重试或联系管理员。",
|
||||
"casUnavailable": "CAS 登录暂时不可用,请稍后重试或联系管理员。",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue