diff --git a/docs/24-ldap-ad-integration.md b/docs/24-ldap-ad-integration.md index 95ee2b6c..ec411e0f 100644 --- a/docs/24-ldap-ad-integration.md +++ b/docs/24-ldap-ad-integration.md @@ -169,6 +169,21 @@ display name、email 和 avatar 的覆盖行为继续由统一身份核心的 Pr 低基数指标名为 `skillhub.auth.ldap`,标签只包含 `provider`、`transport` 和 `result`。 +部署后的最小 operator smoke 可以使用仓库中的脚本执行。凭据只通过环境变量传入,不会 +写入脚本或日志: + +```bash +LDAP_SMOKE_PROVIDER=ldap-main \\ +LDAP_SMOKE_USERNAME=alice \\ +LDAP_SMOKE_PASSWORD="$LDAP_TEST_PASSWORD" \\ +LDAP_SMOKE_RENAMED_USERNAME=alice-renamed \\ +./scripts/ldap-smoke-test.sh http://127.0.0.1:8080 +``` + +脚本验证健康检查、认证目录、首次/重复登录、错误密码、未知账号、响应脱敏,并在提供 +`LDAP_SMOKE_RENAMED_USERNAME` 时验证 username 变化仍解析到同一平台用户。email collision、 +多结果、Subject 缺失和 TLS 证书错误需要由真实目录 fixture 或部署配置单独覆盖。 + ## 6. 升级与回滚 - 新 Provider 和全部配置默认关闭,不修改数据库 schema、Spring Session 序列化或现有 diff --git a/scripts/ldap-smoke-test.sh b/scripts/ldap-smoke-test.sh new file mode 100755 index 00000000..7b7ee3e1 --- /dev/null +++ b/scripts/ldap-smoke-test.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PROVIDER="${LDAP_SMOKE_PROVIDER:-ldap}" +USERNAME="${LDAP_SMOKE_USERNAME:-}" +PASSWORD="${LDAP_SMOKE_PASSWORD:-}" +RENAMED_USERNAME="${LDAP_SMOKE_RENAMED_USERNAME:-}" +RENAMED_PASSWORD="${LDAP_SMOKE_RENAMED_PASSWORD:-$PASSWORD}" +WRONG_PASSWORD="${LDAP_SMOKE_WRONG_PASSWORD:-skillhub-invalid-password}" +UNKNOWN_USERNAME="${LDAP_SMOKE_UNKNOWN_USERNAME:-${USERNAME}-unknown}" +COOKIE_JAR="$(mktemp)" +RESPONSE_FILE="$(mktemp)" +PASS=0 +FAIL=0 + +cleanup() { + rm -f "$COOKIE_JAR" "$RESPONSE_FILE" +} +trap cleanup EXIT + +if [[ -z "$USERNAME" || -z "$PASSWORD" ]]; then + echo "ERROR: LDAP_SMOKE_USERNAME and LDAP_SMOKE_PASSWORD are required" >&2 + exit 2 +fi + +csrf_token() { + awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1 +} + +check_status() { + local description="$1" + local actual="$2" + local expected="$3" + if [[ "$actual" == "$expected" ]]; then + echo "PASS: $description (HTTP $actual)" + PASS=$((PASS + 1)) + else + echo "FAIL: $description (expected HTTP $expected, got $actual)" + FAIL=$((FAIL + 1)) + fi +} + +json_user_id() { + python3 - "$RESPONSE_FILE" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as response: + print(json.load(response)["data"]["userId"]) +except (KeyError, TypeError, json.JSONDecodeError): + raise SystemExit(1) +PY +} + +echo "=== SkillHub LDAP Operator Smoke Test ===" +echo "Target: $BASE_URL" +echo "Provider: $PROVIDER" +echo + +HEALTH_STATUS="$(curl --retry 3 --retry-delay 1 --max-time 10 -s \ + -o /dev/null -w "%{http_code}" \ + "$BASE_URL/actuator/health" || true)" +check_status "Health endpoint" "$HEALTH_STATUS" "200" + +METHODS_STATUS="$(curl --max-time 10 -s \ + -o "$RESPONSE_FILE" -w "%{http_code}" \ + "$BASE_URL/api/v1/auth/methods" || true)" +if [[ "$METHODS_STATUS" == "200" ]] \ + && PROVIDER_VALUE="$PROVIDER" python3 - "$RESPONSE_FILE" <<'PY' +import json +import os +import sys + +provider = os.environ["PROVIDER_VALUE"] +try: + methods = json.load(open(sys.argv[1], encoding="utf-8"))["data"] +except (OSError, KeyError, TypeError, json.JSONDecodeError): + raise SystemExit(1) + +raise SystemExit(0 if any( + method.get("provider") == provider + and method.get("methodType") == "DIRECT_PASSWORD" + for method in methods +) else 1) +PY +then + echo "PASS: LDAP provider is exposed as a direct-password method" + PASS=$((PASS + 1)) +else + echo "FAIL: LDAP provider is not exposed as a direct-password method (HTTP $METHODS_STATUS)" + FAIL=$((FAIL + 1)) +fi + +# Obtain a CSRF token before the first state-changing request. +curl --max-time 10 -s -c "$COOKIE_JAR" \ + "$BASE_URL/api/v1/auth/me" >/dev/null || true +CSRF_TOKEN="$(csrf_token)" +if [[ -z "$CSRF_TOKEN" ]]; then + echo "FAIL: server did not issue an XSRF token" + FAIL=$((FAIL + 1)) +else + echo "PASS: XSRF token issued" + PASS=$((PASS + 1)) +fi + +LOGIN_STATUS="$(curl --max-time 15 -s \ + -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/direct/login" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 - "$PROVIDER" "$USERNAME" "$PASSWORD" <<'PY' +import json +import sys +print(json.dumps({ + "provider": sys.argv[1], + "username": sys.argv[2], + "password": sys.argv[3], +})) +PY +)" || true)" +check_status "LDAP first login" "$LOGIN_STATUS" "200" +FIRST_USER_ID="" +if [[ "$LOGIN_STATUS" == "200" ]]; then + FIRST_USER_ID="$(json_user_id || true)" + if [[ -n "$FIRST_USER_ID" ]]; then + echo "PASS: first login returned a platform user id" + PASS=$((PASS + 1)) + else + echo "FAIL: first login response did not contain a user id" + FAIL=$((FAIL + 1)) + fi +fi + +LOGOUT_CSRF="$(csrf_token)" +LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $LOGOUT_CSRF" || true)" +if [[ "$LOGIN_STATUS" == "200" ]]; then + if [[ "$LOGOUT_STATUS" == "200" || "$LOGOUT_STATUS" == "204" || "$LOGOUT_STATUS" == "302" ]]; then + echo "PASS: logout after first login (HTTP $LOGOUT_STATUS)" + PASS=$((PASS + 1)) + else + echo "FAIL: logout after first login (got HTTP $LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) + fi +fi + +CSRF_TOKEN="$(csrf_token)" +REPEAT_STATUS="$(curl --max-time 15 -s \ + -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/direct/login" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 - "$PROVIDER" "$USERNAME" "$PASSWORD" <<'PY' +import json +import sys +print(json.dumps({ + "provider": sys.argv[1], + "username": sys.argv[2], + "password": sys.argv[3], +})) +PY +)" || true)" +check_status "LDAP repeat login" "$REPEAT_STATUS" "200" +if [[ "$REPEAT_STATUS" == "200" && -n "$FIRST_USER_ID" ]]; then + REPEAT_USER_ID="$(json_user_id || true)" + if [[ "$REPEAT_USER_ID" == "$FIRST_USER_ID" ]]; then + echo "PASS: repeat login resolved the same platform user" + PASS=$((PASS + 1)) + else + echo "FAIL: repeat login resolved a different platform user" + FAIL=$((FAIL + 1)) + fi +fi + +if [[ -n "$RENAMED_USERNAME" && "$REPEAT_STATUS" == "200" ]]; then + LOGOUT_CSRF="$(csrf_token)" + curl --max-time 10 -s -o /dev/null \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $LOGOUT_CSRF" || true + CSRF_TOKEN="$(csrf_token)" + RENAMED_STATUS="$(curl --max-time 15 -s \ + -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/direct/login" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 - "$PROVIDER" "$RENAMED_USERNAME" "$RENAMED_PASSWORD" <<'PY' +import json +import sys +print(json.dumps({ + "provider": sys.argv[1], + "username": sys.argv[2], + "password": sys.argv[3], +})) +PY +)" || true)" + check_status "LDAP login after username change" "$RENAMED_STATUS" "200" + if [[ "$RENAMED_STATUS" == "200" && -n "$FIRST_USER_ID" ]]; then + RENAMED_USER_ID="$(json_user_id || true)" + if [[ "$RENAMED_USER_ID" == "$FIRST_USER_ID" ]]; then + echo "PASS: username change preserved the stable platform identity" + PASS=$((PASS + 1)) + else + echo "FAIL: username change created or resolved another platform identity" + FAIL=$((FAIL + 1)) + fi + fi +else + echo "INFO: LDAP_SMOKE_RENAMED_USERNAME not set; username-change check skipped" +fi + +if [[ "$REPEAT_STATUS" == "200" ]]; then + LOGOUT_CSRF="$(csrf_token)" + curl --max-time 10 -s -o /dev/null \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $LOGOUT_CSRF" || true +fi + +CSRF_TOKEN="$(csrf_token)" +INVALID_PASSWORD_STATUS="$(curl --max-time 15 -s \ + -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/direct/login" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 - "$PROVIDER" "$USERNAME" "$WRONG_PASSWORD" <<'PY' +import json +import sys +print(json.dumps({ + "provider": sys.argv[1], + "username": sys.argv[2], + "password": sys.argv[3], +})) +PY +)" || true)" +check_status "Invalid LDAP password is rejected" "$INVALID_PASSWORD_STATUS" "401" +if grep -Eiq 'ldap|bind|entryuuid|objectguid|directory|upstream' "$RESPONSE_FILE"; then + echo "FAIL: invalid-password response leaks upstream details" + FAIL=$((FAIL + 1)) +else + echo "PASS: invalid-password response contains no upstream detail" + PASS=$((PASS + 1)) +fi + +CSRF_TOKEN="$(csrf_token)" +UNKNOWN_STATUS="$(curl --max-time 15 -s \ + -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/direct/login" \ + -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 - "$PROVIDER" "$UNKNOWN_USERNAME" "$PASSWORD" <<'PY' +import json +import sys +print(json.dumps({ + "provider": sys.argv[1], + "username": sys.argv[2], + "password": sys.argv[3], +})) +PY +)" || true)" +check_status "Unknown LDAP identity is rejected" "$UNKNOWN_STATUS" "401" +if grep -Eiq 'ldap|bind|entryuuid|objectguid|directory|upstream' "$RESPONSE_FILE"; then + echo "FAIL: unknown-identity response leaks upstream details" + FAIL=$((FAIL + 1)) +else + echo "PASS: unknown-identity response contains no upstream detail" + PASS=$((PASS + 1)) +fi + +if [[ "$PASS" -gt 0 && "$FAIL" -eq 0 ]]; then + echo + echo "LDAP smoke test passed: $PASS checks" + exit 0 +fi + +echo +echo "LDAP smoke test failed: $PASS passed, $FAIL failed" +exit 1 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java index 0d2afd3b..20338f32 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java @@ -61,6 +61,7 @@ public class AccountMergeAppService { private final AccountMergeProviderProofService providerProofService; private final IdentityProviderRegistry providerRegistry; + private final ProviderLoginAppService providerLoginAppService; private final IdentityLinkIntentService identityLinkIntentService; private final IdentityLinkSessionManager @@ -74,6 +75,7 @@ public class AccountMergeAppService { AccountMergeSessionManager sessionManager, AccountMergeProviderProofService providerProofService, IdentityProviderRegistry providerRegistry, + ProviderLoginAppService providerLoginAppService, IdentityLinkIntentService identityLinkIntentService, IdentityLinkSessionManager identityLinkSessionManager, @@ -84,6 +86,7 @@ public class AccountMergeAppService { this.sessionManager = sessionManager; this.providerProofService = providerProofService; this.providerRegistry = providerRegistry; + this.providerLoginAppService = providerLoginAppService; this.identityLinkIntentService = identityLinkIntentService; this.identityLinkSessionManager = @@ -193,7 +196,8 @@ public class AccountMergeAppService { authenticate( route, username, - password), + password, + context), context); return new AccountMergePrimaryProofResponse( result.proof().method(), @@ -324,7 +328,8 @@ public class AccountMergeAppService { authenticate( route, username, - password), + password, + context), context)); } @@ -642,13 +647,18 @@ public class AccountMergeAppService { .ProviderAuthenticationResult authenticate( IdentityProviderRegistry.CredentialRoute route, String username, - String password) { + String password, + IdentityLoginContext context) { try { return route.adapter().authenticate( new CredentialAuthenticationRequest( username, password)); } catch (ProviderAuthenticationException exception) { + providerLoginAppService.recordProviderAuthenticationFailure( + route.provider(), + exception, + context); throw ProviderAuthenticationFailureMapper .mapAccountMerge(exception); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java index f80fa635..027c6bb0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java @@ -70,7 +70,8 @@ public class DirectAuthService { var result = authenticate( route, username, - password); + password, + request); if (result == null) { throw new AuthFlowException( HttpStatus.UNAUTHORIZED, @@ -89,13 +90,18 @@ public class DirectAuthService { private ProviderAuthenticationResult authenticate( IdentityProviderRegistry.CredentialRoute route, String username, - String password) { + String password, + HttpServletRequest request) { try { return route.adapter().authenticate( new CredentialAuthenticationRequest( - username, - password)); + username, + password)); } catch (ProviderAuthenticationException exception) { + providerLoginAppService.recordProviderAuthenticationFailure( + route.provider(), + exception, + request); throw ProviderAuthenticationFailureMapper.map(exception); } } 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 cc4595a8..fc937f28 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 @@ -38,6 +38,7 @@ public class IdentityLinkAppService { private final IdentityLinkIntentService intentService; private final ExternalIdentityLinkService externalLinkService; private final IdentityProviderRegistry providerRegistry; + private final ProviderLoginAppService providerLoginAppService; private final IdentityLinkSessionManager sessionManager; private final AccountMergeSessionManager accountMergeSessionManager; @@ -46,12 +47,14 @@ public class IdentityLinkAppService { IdentityLinkIntentService intentService, ExternalIdentityLinkService externalLinkService, IdentityProviderRegistry providerRegistry, + ProviderLoginAppService providerLoginAppService, IdentityLinkSessionManager sessionManager, AccountMergeSessionManager accountMergeSessionManager) { this.intentService = intentService; this.externalLinkService = externalLinkService; this.providerRegistry = providerRegistry; + this.providerLoginAppService = providerLoginAppService; this.sessionManager = sessionManager; this.accountMergeSessionManager = accountMergeSessionManager; @@ -267,7 +270,7 @@ public class IdentityLinkAppService { actor, intentId, route.provider(), - authenticate(route, username, password)); + authenticate(route, username, password, context)); if (!(outcome instanceof IdentityLinkOutcome.Reauthenticated)) { throw new IllegalStateException( "Credential reauthentication returned an invalid outcome"); @@ -298,7 +301,7 @@ public class IdentityLinkAppService { actor, intentId, route.provider(), - authenticate(route, username, password)); + authenticate(route, username, password, context)); if (!(outcome instanceof IdentityLinkOutcome.Linked)) { throw new IllegalStateException( "Credential link returned an invalid outcome"); @@ -331,13 +334,18 @@ public class IdentityLinkAppService { private ProviderAuthenticationResult authenticate( IdentityProviderRegistry.CredentialRoute route, String username, - String password) { + String password, + IdentityLoginContext context) { try { return route.adapter().authenticate( new CredentialAuthenticationRequest( username, password)); } catch (ProviderAuthenticationException exception) { + providerLoginAppService.recordProviderAuthenticationFailure( + route.provider(), + exception, + context); throw ProviderAuthenticationFailureMapper .mapIdentityLink(exception); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java index 30bebc7b..9b5c5905 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderLoginAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.MDC; @@ -54,6 +55,26 @@ class ProviderLoginAppService { } } + void recordProviderAuthenticationFailure( + ResolvedProviderHandle provider, + ProviderAuthenticationException failure, + IdentityLoginContext context) { + identityLoginService.recordProviderAuthenticationFailure( + provider, + failure.getReasonCode(), + context); + } + + void recordProviderAuthenticationFailure( + ResolvedProviderHandle provider, + ProviderAuthenticationException failure, + HttpServletRequest request) { + recordProviderAuthenticationFailure( + provider, + failure, + context(request)); + } + private IdentityLoginContext context(HttpServletRequest request) { return new IdentityLoginContext( bounded(MDC.get(REQUEST_ID_MDC_KEY), 64), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java index d1db210f..006b5bb0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java @@ -85,6 +85,7 @@ class AccountMergeAppServiceTest { accountMergeSessionManager, mock(AccountMergeProviderProofService.class), providerRegistry, + mock(ProviderLoginAppService.class), identityLinkIntentService, identityLinkSessionManager, metrics, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java index 9b630ddd..678cbf0f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/DirectAuthServiceTest.java @@ -226,7 +226,13 @@ class DirectAuthServiceTest { .isEqualTo( org.springframework.http.HttpStatus .SERVICE_UNAVAILABLE); - verifyNoInteractions(localAuth, providerLogin, sessions); + org.mockito.Mockito.verify(providerLogin) + .recordProviderAuthenticationFailure( + org.mockito.ArgumentMatchers.eq(route.provider()), + org.mockito.ArgumentMatchers.any( + ProviderAuthenticationException.class), + org.mockito.ArgumentMatchers.eq(request)); + verifyNoInteractions(localAuth, sessions); } private static ProviderAuthenticationResult result() { 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 index 267d4adf..81e15463 100644 --- 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 @@ -172,6 +172,8 @@ class IdentityLinkAppServiceTest { mock(ExternalIdentityLinkService.class); private final IdentityProviderRegistry registry = mock(IdentityProviderRegistry.class); + private final ProviderLoginAppService providerLoginAppService = + mock(ProviderLoginAppService.class); private final IdentityLinkSessionManager sessionManager = mock(IdentityLinkSessionManager.class); private final AccountMergeSessionManager @@ -194,6 +196,7 @@ class IdentityLinkAppServiceTest { intentService, externalLinkService, registry, + providerLoginAppService, sessionManager, accountMergeSessionManager); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java index 1dd738b4..3f01bbe4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapperTest.java @@ -5,6 +5,8 @@ import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.identity.IdentityLinkException; import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; +import com.iflytek.skillhub.auth.merge.AccountMergeException; +import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import org.junit.jupiter.api.Test; @@ -18,6 +20,10 @@ class ProviderAuthenticationFailureMapperTest { ProviderAuthenticationFailureCode .UPSTREAM_INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED); + assertMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + HttpStatus.UNAUTHORIZED); assertMapping( ProviderAuthenticationFailureCode.REPLAY_DETECTED, HttpStatus.UNAUTHORIZED); @@ -45,6 +51,10 @@ class ProviderAuthenticationFailureMapperTest { ProviderAuthenticationFailureCode .UPSTREAM_INVALID_CREDENTIALS, IdentityLinkFailureCode.PROVIDER_AUTHENTICATION_FAILED); + assertIdentityLinkMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + IdentityLinkFailureCode.PROVIDER_AUTHENTICATION_FAILED); assertIdentityLinkMapping( ProviderAuthenticationFailureCode.UPSTREAM_ACCESS_DENIED, IdentityLinkFailureCode.PROVIDER_AUTHENTICATION_FAILED); @@ -66,6 +76,23 @@ class ProviderAuthenticationFailureMapperTest { IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); } + @Test + void mapsStableProviderFailuresToAccountMergeReasonCodes() { + assertAccountMergeMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS, + AccountMergeFailureCode + .MERGE_PROVIDER_AUTHENTICATION_FAILED); + assertAccountMergeMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + AccountMergeFailureCode + .MERGE_PROVIDER_AUTHENTICATION_FAILED); + assertAccountMergeMapping( + ProviderAuthenticationFailureCode.UPSTREAM_UNAVAILABLE, + AccountMergeFailureCode.MERGE_PROVIDER_UNAVAILABLE); + } + private void assertMapping( ProviderAuthenticationFailureCode reasonCode, HttpStatus status) { @@ -98,4 +125,22 @@ class ProviderAuthenticationFailureMapperTest { assertThat(mapped.getMessage()) .doesNotContain("private upstream detail"); } + + private void assertAccountMergeMapping( + ProviderAuthenticationFailureCode providerReasonCode, + AccountMergeFailureCode accountMergeReasonCode) { + AccountMergeException mapped = + ProviderAuthenticationFailureMapper.mapAccountMerge( + new ProviderAuthenticationException( + providerReasonCode, + new IllegalStateException( + "private upstream detail"))); + + assertThat(mapped.getStatus()) + .isEqualTo(accountMergeReasonCode.status()); + assertThat(mapped.getReasonCode()) + .isEqualTo(accountMergeReasonCode); + assertThat(mapped.getMessage()) + .doesNotContain("private upstream detail"); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java index 42d8096d..5fc347ad 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.identity; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import java.sql.SQLException; import java.util.Objects; import org.slf4j.Logger; @@ -83,6 +84,41 @@ class DefaultExternalIdentityLoginService } } + @Override + public void recordProviderAuthenticationFailure( + ResolvedProviderHandle provider, + ProviderAuthenticationFailureCode failureCode, + IdentityLoginContext context) { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(failureCode, "failureCode"); + Objects.requireNonNull(context, "context"); + + String providerCode = provider.providerCode(); + String protocol = "unknown"; + try { + ProviderDescriptor descriptor = descriptorSource.require(provider); + providerCode = descriptor.providerCode(); + protocol = descriptor.protocol(); + } catch (RuntimeException descriptorFailure) { + log.warn( + "Unable to resolve provider descriptor for denial audit '{}'", + providerCode); + } + try { + securityAuditWriter.recordProviderDenied( + providerCode, + protocol, + failureCode, + context); + } catch (RuntimeException auditFailure) { + log.error( + "Provider denial audit failed for provider '{}' and reason '{}'", + providerCode, + failureCode, + auditFailure); + } + } + private void recordDeniedAudit( String providerCode, String protocol, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java index 0d76bdf7..3de6f359 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.auth.identity; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; + /** * The only application-facing facade for converting externally authenticated * provider facts into a platform login outcome. @@ -10,4 +12,13 @@ public interface ExternalIdentityLoginService { ResolvedProviderHandle provider, ProviderAuthenticationResult result, IdentityLoginContext context); + + /** + * Records a credential-provider denial that happened before an assertion + * could enter the identity core. + */ + void recordProviderAuthenticationFailure( + ResolvedProviderHandle provider, + ProviderAuthenticationFailureCode failureCode, + IdentityLoginContext context); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java index 68ff3406..687afc5a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java @@ -2,8 +2,12 @@ package com.iflytek.skillhub.auth.identity; import java.net.URI; import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -120,13 +124,9 @@ final class IdentityAssertionFactory { return primarySubject.value(); }); - Optional email = firstValue( - result.attributes(), descriptor.emailAttributes()) - .filter(value -> !value.value().isBlank()) - .map(value -> new EmailClaim( - value.value(), - emailAssurance(descriptor, value) - .clampTo(descriptor.emailAssuranceLimit()))); + Optional email = selectEmail( + descriptor, + result.attributes()); Optional avatarUrl = firstValue( result.attributes(), descriptor.avatarAttributes()) @@ -149,6 +149,95 @@ final class IdentityAssertionFactory { return Optional.empty(); } + private Optional selectEmail( + ProviderDescriptor descriptor, + Map> attributes) { + List candidates = new ArrayList<>(); + for (int attributePriority = 0; + attributePriority < descriptor.emailAttributes().size(); + attributePriority++) { + String attribute = descriptor.emailAttributes() + .get(attributePriority); + List values = attributes.get(attribute); + if (values == null) { + continue; + } + for (ProviderAttributeValue value : values) { + Optional normalized = normalizeEmail(value.value()); + if (normalized.isEmpty()) { + continue; + } + EmailAssurance assurance = emailAssurance( + descriptor, + value).clampTo(descriptor.emailAssuranceLimit()); + candidates.add(new EmailCandidate( + normalized.orElseThrow(), + assurance, + attributePriority)); + } + } + if (candidates.isEmpty()) { + return Optional.empty(); + } + + Map deduplicated = + new LinkedHashMap<>(); + for (EmailCandidate candidate : candidates) { + deduplicated.merge( + candidate.value(), + candidate, + (existing, duplicate) -> existing.assurance().ordinal() + >= duplicate.assurance().ordinal() + ? existing + : duplicate); + } + + long trustedEmailCount = deduplicated.values().stream() + .filter(candidate -> candidate.assurance() + .isVerifiedOrAuthoritative()) + .map(EmailCandidate::value) + .distinct() + .count(); + if (trustedEmailCount > 1) { + throw invalidAssertion(); + } + + return deduplicated.values().stream() + .min(Comparator + .comparing( + EmailCandidate::assurance, + Comparator.reverseOrder()) + .thenComparingInt(EmailCandidate::attributePriority) + .thenComparing(EmailCandidate::value)) + .map(candidate -> new EmailClaim( + candidate.value(), + candidate.assurance())); + } + + private Optional normalizeEmail(String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.strip().toLowerCase(Locale.ROOT); + int at = normalized.indexOf('@'); + if (normalized.length() > 256 + || at <= 0 + || at != normalized.lastIndexOf('@') + || at == normalized.length() - 1 + || normalized.chars().anyMatch( + character -> Character.isISOControl(character) + || Character.isWhitespace(character))) { + return Optional.empty(); + } + return Optional.of(normalized); + } + + private record EmailCandidate( + String value, + EmailAssurance assurance, + int attributePriority) { + } + private URI parseAvatarUri(String value) { try { URI uri = new URI(value); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriter.java index 9174f4bb..570586c6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriter.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.identity; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import com.iflytek.skillhub.domain.audit.AuditLogService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -47,4 +48,27 @@ class IdentitySecurityAuditWriter { + failureCode.name() + "\"}"); } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordProviderDenied( + String providerCode, + String protocol, + ProviderAuthenticationFailureCode failureCode, + IdentityLoginContext context) { + auditLogService.record( + null, + "IDENTITY_LOGIN_DENIED", + "IDENTITY_PROVIDER", + null, + context.requestId(), + context.clientIp(), + context.userAgent(), + "{\"providerCode\":\"" + + providerCode + + "\",\"protocol\":\"" + + protocol + + "\",\"reason\":\"" + + failureCode.name() + + "\"}"); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfiguration.java index 1a5e0d10..a1445381 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfiguration.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfiguration.java @@ -34,6 +34,20 @@ final class LdapProviderConfiguration { private static final int MAX_SECRET_LENGTH = 4096; private static final int MAX_DN_LENGTH = 2048; private static final int MAX_FILTER_LENGTH = 1024; + private static final Set UNSTABLE_SUBJECT_NAMES = Set.of( + "uid", + "mail", + "email", + "username", + "userprincipalname", + "samaccountname", + "cn", + "displayname", + "dn", + "distinguishedname", + "entrydn", + "uidnumber", + "employeenumber"); private final LdapProperties properties; private final Environment environment; @@ -83,6 +97,7 @@ final class LdapProviderConfiguration { String subjectType = optionalSubjectType( properties.getSubjectType()) .orElseGet(() -> defaultSubjectType(directoryType)); + validateSubjectMapping(subjectAttribute, subjectType); Optional usernameAttribute = optionalAttribute( properties.getUsernameAttribute()); Optional displayNameAttribute = optionalAttribute( @@ -311,6 +326,23 @@ final class LdapProviderConfiguration { return Optional.of(value); } + private void validateSubjectMapping( + String subjectAttribute, + String subjectType) { + if (isUnstableSubjectName(subjectAttribute) + || isUnstableSubjectName(subjectType)) { + throw invalidConfiguration(); + } + } + + private boolean isUnstableSubjectName(String value) { + String normalized = value + .toLowerCase(Locale.ROOT) + .replace("_", "") + .replace("-", ""); + return UNSTABLE_SUBJECT_NAMES.contains(normalized); + } + private Duration requireDuration(Duration value) { if (value == null || value.isZero() diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java index a4fa2b2d..deb48922 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.sql.SQLException; import java.time.Instant; @@ -206,6 +207,30 @@ class DefaultExternalIdentityLoginServiceTest { .isEqualTo(IdentityFailureCode.ACCESS_DENIED); } + @Test + void recordsProviderFailureBeforeIdentityCore() { + ResolvedProviderHandle handle = + new DefaultResolvedProviderHandle("github"); + IdentityLoginContext context = new IdentityLoginContext( + "request-provider-failure", + "127.0.0.1", + "identity-test"); + when(descriptorSource.require(handle)).thenReturn(descriptor); + + service.recordProviderAuthenticationFailure( + handle, + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + context); + + verify(securityAuditWriter).recordProviderDenied( + "github", + "oauth2-github", + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + context); + } + @Test void nonUniqueIntegrityFailureIsNotRetriedOrMisclassified() { ResolvedProviderHandle handle = diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java index 32678405..960931fc 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java @@ -256,6 +256,77 @@ class IdentityAssertionFactoryTest { .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); } + @Test + void selectsEmailByAssuranceThenDescriptorPriorityNotAdapterOrder() { + ProviderDescriptor descriptor = emailDescriptor( + List.of("email", "mail"), + EmailAssurance.VERIFIED); + ProviderAuthenticationResult result = result( + new SubjectCandidate("stable_id", "stable-123"), + List.of(), + Map.of( + "email", values( + "preferred@example.com", + ProviderAttributeTrust.ASSERTED), + "mail", values( + "verified@example.com", + ProviderAttributeTrust.VERIFIED)), + "oidc"); + + assertThat(factory.create(descriptor, result).profile().email()) + .contains(new EmailClaim( + "verified@example.com", + EmailAssurance.VERIFIED)); + } + + @Test + void deduplicatesNormalizedEmailAndKeepsHighestAssurance() { + ProviderDescriptor descriptor = emailDescriptor( + List.of("email", "mail"), + EmailAssurance.VERIFIED); + ProviderAuthenticationResult result = result( + new SubjectCandidate("stable_id", "stable-123"), + List.of(), + Map.of( + "email", List.of( + new ProviderAttributeValue( + " Alice@Example.COM ", + ProviderAttributeTrust.ASSERTED)), + "mail", values( + "alice@example.com", + ProviderAttributeTrust.VERIFIED)), + "oidc"); + + assertThat(factory.create(descriptor, result).profile().email()) + .contains(new EmailClaim( + "alice@example.com", + EmailAssurance.VERIFIED)); + } + + @Test + void rejectsDifferentTrustedEmailClaimsInsteadOfChoosingOne() { + ProviderDescriptor descriptor = emailDescriptor( + List.of("email"), + EmailAssurance.AUTHORITATIVE); + ProviderAuthenticationResult result = result( + new SubjectCandidate("stable_id", "stable-123"), + List.of(), + Map.of( + "email", List.of( + new ProviderAttributeValue( + "alice@example.com", + ProviderAttributeTrust.VERIFIED), + new ProviderAttributeValue( + "mallory@example.com", + ProviderAttributeTrust.VERIFIED))), + "oidc"); + + assertThatThrownBy(() -> factory.create(descriptor, result)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } + @Test void providerResultDefensivelyCopiesNestedCollections() { List loginValues = new ArrayList<>( @@ -301,6 +372,23 @@ class IdentityAssertionFactoryTest { ); } + private static ProviderDescriptor emailDescriptor( + List emailAttributes, + EmailAssurance emailAssuranceLimit) { + return new ProviderDescriptor( + "corp", + "oidc", + "https://id.example.com", + "Corporate Identity", + "stable_id", + "stable_id", + Map.of("stable_id", SubjectCanonicalizer.EXACT), + List.of("name"), + emailAttributes, + List.of("picture"), + emailAssuranceLimit); + } + private static ProviderAuthenticationResult result( SubjectCandidate primary, List alternates, diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriterTest.java index 8338a2ec..81658d0c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentitySecurityAuditWriterTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; import com.iflytek.skillhub.domain.audit.AuditLogService; import org.junit.jupiter.api.Test; @@ -36,6 +37,31 @@ class IdentitySecurityAuditWriterTest { "IDENTITY_LOGIN_DENIED"); } + @Test + void recordsCredentialProviderFailureAsLoginDenied() { + writer.recordProviderDenied( + "corporate-ldap", + "ldap", + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + new IdentityLoginContext( + "request-2", + "127.0.0.2", + "identity-test")); + + verify(auditLogService).record( + isNull(), + eq("IDENTITY_LOGIN_DENIED"), + eq("IDENTITY_PROVIDER"), + isNull(), + eq("request-2"), + eq("127.0.0.2"), + eq("identity-test"), + eq("{\"providerCode\":\"corporate-ldap\"," + + "\"protocol\":\"ldap\"," + + "\"reason\":\"UPSTREAM_IDENTITY_NOT_FOUND\"}")); + } + private void assertAuditAction( IdentityFailureCode failureCode, String expectedAction) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfigurationTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfigurationTest.java index c9a15929..7c287e6c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfigurationTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfigurationTest.java @@ -86,6 +86,30 @@ class LdapProviderConfigurationTest { .doesNotContain("bindPassword"); } + @Test + void rejectsUnstableExplicitSubjectAttribute() { + LdapProperties properties = validProperties(); + properties.setSubjectAttribute("uid"); + + assertThatThrownBy(() -> configuration(properties, "prod") + .requireResolved()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid LDAP provider configuration"); + } + + @Test + void rejectsUnstableCustomSubjectType() { + LdapProperties properties = validProperties(); + properties.setDirectoryType("CUSTOM"); + properties.setSubjectAttribute("immutableId"); + properties.setSubjectType("mail"); + + assertThatThrownBy(() -> configuration(properties, "prod") + .requireResolved()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid LDAP provider configuration"); + } + private static LdapProviderConfiguration configuration( LdapProperties properties, String profile) {