mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(auth): harden LDAP identity assertions
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
a35bdce3c6
commit
3c8b62a664
19 changed files with 775 additions and 18 deletions
|
|
@ -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
287
scripts/ldap-smoke-test.sh
Executable 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class AccountMergeAppServiceTest {
|
|||
accountMergeSessionManager,
|
||||
mock(AccountMergeProviderProofService.class),
|
||||
providerRegistry,
|
||||
mock(ProviderLoginAppService.class),
|
||||
identityLinkIntentService,
|
||||
identityLinkSessionManager,
|
||||
metrics,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
+ "\"}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue