fix(auth): harden LDAP identity assertions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 21:54:49 +08:00
parent a35bdce3c6
commit 3c8b62a664
19 changed files with 775 additions and 18 deletions

View file

@ -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 序列化或现有

287
scripts/ldap-smoke-test.sh Executable file
View file

@ -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

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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),

View file

@ -85,6 +85,7 @@ class AccountMergeAppServiceTest {
accountMergeSessionManager,
mock(AccountMergeProviderProofService.class),
providerRegistry,
mock(ProviderLoginAppService.class),
identityLinkIntentService,
identityLinkSessionManager,
metrics,

View file

@ -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() {

View file

@ -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);

View file

@ -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");
}
}

View file

@ -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,

View file

@ -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);
}

View file

@ -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<EmailClaim> email = firstValue(
result.attributes(), descriptor.emailAttributes())
.filter(value -> !value.value().isBlank())
.map(value -> new EmailClaim(
value.value(),
emailAssurance(descriptor, value)
.clampTo(descriptor.emailAssuranceLimit())));
Optional<EmailClaim> email = selectEmail(
descriptor,
result.attributes());
Optional<URI> avatarUrl = firstValue(
result.attributes(), descriptor.avatarAttributes())
@ -149,6 +149,95 @@ final class IdentityAssertionFactory {
return Optional.empty();
}
private Optional<EmailClaim> selectEmail(
ProviderDescriptor descriptor,
Map<String, List<ProviderAttributeValue>> attributes) {
List<EmailCandidate> candidates = new ArrayList<>();
for (int attributePriority = 0;
attributePriority < descriptor.emailAttributes().size();
attributePriority++) {
String attribute = descriptor.emailAttributes()
.get(attributePriority);
List<ProviderAttributeValue> values = attributes.get(attribute);
if (values == null) {
continue;
}
for (ProviderAttributeValue value : values) {
Optional<String> 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<String, EmailCandidate> 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<String> 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);

View file

@ -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()
+ "\"}");
}
}

View file

@ -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<String> 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<String> usernameAttribute = optionalAttribute(
properties.getUsernameAttribute());
Optional<String> 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()

View file

@ -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 =

View file

@ -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<ProviderAttributeValue> loginValues = new ArrayList<>(
@ -301,6 +372,23 @@ class IdentityAssertionFactoryTest {
);
}
private static ProviderDescriptor emailDescriptor(
List<String> 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<SubjectCandidate> alternates,

View file

@ -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) {

View file

@ -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) {