From a35bdce3c688cc303c526c47fa6d32d3d9c155ba Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:13:32 +0800 Subject: [PATCH 1/2] feat(auth): add LDAP and Active Directory adapter Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 31 ++ README.md | 1 + charts/skillhub/templates/configmap.yaml | 23 + charts/skillhub/templates/secret.yaml | 5 + .../skillhub/templates/server-deployment.yaml | 122 +++++ charts/skillhub/templates/validate.yaml | 31 ++ .../skillhub/tests/configuration-contracts.sh | 31 ++ charts/skillhub/values.schema.json | 33 +- charts/skillhub/values.yaml | 25 + compose.release.yml | 25 + deploy/k8s/README.md | 7 + deploy/k8s/base/backend-deployment.yaml | 123 +++++ deploy/k8s/base/configmap.yaml | 27 + deploy/k8s/base/secret.yaml.example | 3 + docs/24-ldap-ad-integration.md | 180 ++++++ scripts/tests/validate-release-config-test.sh | 36 ++ scripts/validate-release-config.sh | 42 ++ .../skillhub/service/CasLoginAppService.java | 1 + .../ProviderAuthenticationFailureMapper.java | 3 + .../src/main/resources/application.yml | 26 + server/skillhub-auth/pom.xml | 4 + .../identity/IdentityAssertionFactory.java | 12 +- .../auth/identity/ProviderDescriptor.java | 33 ++ .../ReconciledIdentityProviderCatalog.java | 1 + .../auth/ldap/LdapAuthenticatedEntry.java | 39 ++ .../auth/ldap/LdapAuthenticationAdapter.java | 217 ++++++++ .../auth/ldap/LdapAuthenticationMetrics.java | 56 ++ .../auth/ldap/LdapDirectoryClient.java | 14 + .../skillhub/auth/ldap/LdapDirectoryType.java | 7 + .../skillhub/auth/ldap/LdapProperties.java | 239 ++++++++ .../auth/ldap/LdapProviderConfiguration.java | 507 +++++++++++++++++ .../skillhub/auth/ldap/LdapTransport.java | 7 + .../ldap/UnboundIdLdapDirectoryClient.java | 518 ++++++++++++++++++ .../skillhub/auth/ldap/package-info.java | 4 + .../ProviderAuthenticationFailureCode.java | 1 + .../provider/ProviderInstanceDefinition.java | 36 ++ .../IdentityAssertionFactoryTest.java | 41 ++ .../ldap/LdapAuthenticationAdapterTest.java | 161 ++++++ .../ldap/LdapProviderConfigurationTest.java | 111 ++++ .../UnboundIdLdapDirectoryClientTest.java | 511 +++++++++++++++++ 40 files changed, 3292 insertions(+), 2 deletions(-) create mode 100644 docs/24-ldap-ad-integration.md create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticatedEntry.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapter.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationMetrics.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryType.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProperties.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfiguration.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTransport.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/package-info.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapterTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfigurationTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClientTest.java diff --git a/.env.release.example b/.env.release.example index c623ce698..5692d5d9a 100644 --- a/.env.release.example +++ b/.env.release.example @@ -128,6 +128,37 @@ SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI= +# Optional: LDAP/Active Directory credential login. The provider remains +# hidden until enabled and every required field is valid. Keep provider code, +# authority and stable subject mapping unchanged after identities are bound. +SKILLHUB_AUTH_LDAP_ENABLED=false +SKILLHUB_AUTH_LDAP_PROVIDER_CODE=ldap-main +SKILLHUB_AUTH_LDAP_DISPLAY_NAME=Corporate Directory +SKILLHUB_AUTH_LDAP_AUTHORITY=corp-directory +SKILLHUB_AUTH_LDAP_URL=ldaps://ldap.example.com:636 +SKILLHUB_AUTH_LDAP_START_TLS=false +SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING=false +SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE=OPENLDAP +SKILLHUB_AUTH_LDAP_BASE_DN=dc=example,dc=com +SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE=ou=people +SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER=(uid={0}) +SKILLHUB_AUTH_LDAP_BIND_DN=cn=skillhub,ou=services,dc=example,dc=com +SKILLHUB_AUTH_LDAP_BIND_PASSWORD= +# OPENLDAP defaults to entryUUID/ldap_entry_uuid; ACTIVE_DIRECTORY defaults +# to objectGUID/ad_object_guid. Leave both blank to use those safe defaults. +SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE= +SKILLHUB_AUTH_LDAP_SUBJECT_TYPE= +SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE=uid +SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE=displayName +SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE=mail +SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE= +SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE=false +SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT=PT5S +SKILLHUB_AUTH_LDAP_READ_TIMEOUT=PT10S +SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT=PT2S +SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS=16 +SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES=16 + # Optional: CAS 2.0/3.0 browser login. The provider remains hidden until # enabled and every required field is valid. Keep provider code and authority # stable after users have created identity bindings. diff --git a/README.md b/README.md index cb348fb18..6aa615f14 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ with the Skill's source and the problem it solves, or submit a PR by following t - 📖 **[User Guide](https://iflytek.github.io/skillhub/)** — Skill publishing, search, CLI usage and other user guides - 🛠️ **[Developer Docs](https://zread.ai/iflytek/skillhub)** — Architecture, API reference, local development, deployment and operations - 🔐 **[CAS 2.0/3.0 Integration](./docs/23-cas-integration.md)** — Provider configuration, identity mapping, deployment and verification +- 🔐 **[LDAP/Active Directory Integration](./docs/24-ldap-ad-integration.md)** — Secure credential login, stable identity mapping and deployment ## Highlights diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 0c5561fff..75b579606 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -59,6 +59,29 @@ data: device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }} auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }} auth-direct-provider: {{ .Values.auth.direct.provider | quote }} + auth-ldap-enabled: {{ .Values.auth.ldap.enabled | quote }} + auth-ldap-provider-code: {{ .Values.auth.ldap.providerCode | quote }} + auth-ldap-display-name: {{ .Values.auth.ldap.displayName | quote }} + auth-ldap-authority: {{ .Values.auth.ldap.authority | quote }} + auth-ldap-url: {{ .Values.auth.ldap.url | quote }} + auth-ldap-start-tls: {{ .Values.auth.ldap.startTls | quote }} + auth-ldap-directory-type: {{ .Values.auth.ldap.directoryType | quote }} + auth-ldap-base-dn: {{ .Values.auth.ldap.baseDn | quote }} + auth-ldap-user-search-base: {{ .Values.auth.ldap.userSearchBase | quote }} + auth-ldap-user-search-filter: {{ .Values.auth.ldap.userSearchFilter | quote }} + auth-ldap-bind-dn: {{ .Values.auth.ldap.bindDn | quote }} + auth-ldap-subject-attribute: {{ .Values.auth.ldap.subjectAttribute | quote }} + auth-ldap-subject-type: {{ .Values.auth.ldap.subjectType | quote }} + auth-ldap-username-attribute: {{ .Values.auth.ldap.usernameAttribute | quote }} + auth-ldap-display-name-attribute: {{ .Values.auth.ldap.displayNameAttribute | quote }} + auth-ldap-email-attribute: {{ .Values.auth.ldap.emailAttribute | quote }} + auth-ldap-avatar-url-attribute: {{ .Values.auth.ldap.avatarUrlAttribute | quote }} + auth-ldap-email-authoritative: {{ .Values.auth.ldap.emailAuthoritative | quote }} + auth-ldap-connect-timeout: {{ .Values.auth.ldap.connectTimeout | quote }} + auth-ldap-read-timeout: {{ .Values.auth.ldap.readTimeout | quote }} + auth-ldap-pool-wait-timeout: {{ .Values.auth.ldap.poolWaitTimeout | quote }} + auth-ldap-max-concurrent-requests: {{ .Values.auth.ldap.maxConcurrentRequests | quote }} + auth-ldap-max-attribute-values: {{ .Values.auth.ldap.maxAttributeValues | quote }} auth-cas-enabled: {{ .Values.auth.cas.enabled | quote }} auth-cas-provider-code: {{ .Values.auth.cas.providerCode | quote }} auth-cas-display-name: {{ .Values.auth.cas.displayName | quote }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 8e28c9114..c1085544a 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -59,6 +59,11 @@ stringData: oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | quote }} {{- end }} + # LDAP service-account password (optional unless LDAP is enabled) + {{- if .Values.secrets.ldapBindPassword }} + ldap-bind-password: {{ .Values.secrets.ldapBindPassword | quote }} + {{- end }} + # Scanner LLM 配置 (optional) {{- if .Values.secrets.scannerLlmApiKey }} skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 4620fdae8..28fc24b49 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -320,6 +320,128 @@ spec: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: auth-direct-enabled + {{- if .Values.auth.ldap.enabled }} + - name: SKILLHUB_AUTH_LDAP_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-enabled + - name: SKILLHUB_AUTH_LDAP_PROVIDER_CODE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-provider-code + - name: SKILLHUB_AUTH_LDAP_DISPLAY_NAME + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-display-name + - name: SKILLHUB_AUTH_LDAP_AUTHORITY + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-authority + - name: SKILLHUB_AUTH_LDAP_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-url + - name: SKILLHUB_AUTH_LDAP_START_TLS + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-start-tls + - name: SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-directory-type + - name: SKILLHUB_AUTH_LDAP_BASE_DN + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-base-dn + - name: SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-user-search-base + - name: SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-user-search-filter + - name: SKILLHUB_AUTH_LDAP_BIND_DN + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-bind-dn + - name: SKILLHUB_AUTH_LDAP_BIND_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: ldap-bind-password + - name: SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-subject-attribute + - name: SKILLHUB_AUTH_LDAP_SUBJECT_TYPE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-subject-type + - name: SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-username-attribute + - name: SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-display-name-attribute + - name: SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-email-attribute + - name: SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-avatar-url-attribute + - name: SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-email-authoritative + - name: SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-connect-timeout + - name: SKILLHUB_AUTH_LDAP_READ_TIMEOUT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-read-timeout + - name: SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-pool-wait-timeout + - name: SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-max-concurrent-requests + - name: SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-ldap-max-attribute-values + {{- end }} - name: SKILLHUB_AUTH_CAS_ENABLED valueFrom: configMapKeyRef: diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index ee6bc4ccc..33e1d48ab 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -12,6 +12,37 @@ {{- if and .Values.auth.accountMerge.enabled (not .Values.auth.accountMerge.sessionCutoverComplete) -}} {{- fail "auth.accountMerge.enabled=true requires auth.accountMerge.sessionCutoverComplete=true after rotating the Spring Session namespace" -}} {{- end -}} +{{- if .Values.auth.ldap.enabled -}} +{{- if not .Values.auth.ldap.url -}} +{{- fail "auth.ldap.enabled=true requires auth.ldap.url" -}} +{{- end -}} +{{- if not .Values.auth.ldap.baseDn -}} +{{- fail "auth.ldap.enabled=true requires auth.ldap.baseDn" -}} +{{- end -}} +{{- if not .Values.auth.ldap.bindDn -}} +{{- fail "auth.ldap.enabled=true requires auth.ldap.bindDn" -}} +{{- end -}} +{{- if and (not .Values.existingSecret) (not .Values.secrets.ldapBindPassword) -}} +{{- fail "auth.ldap.enabled=true requires secrets.ldapBindPassword or existingSecret key ldap-bind-password" -}} +{{- end -}} +{{- if and (eq .Values.auth.ldap.directoryType "CUSTOM") (or (not .Values.auth.ldap.subjectAttribute) (not .Values.auth.ldap.subjectType)) -}} +{{- fail "auth.ldap.directoryType=CUSTOM requires subjectAttribute and subjectType" -}} +{{- end -}} +{{- if hasPrefix "ldaps://" .Values.auth.ldap.url -}} +{{- if .Values.auth.ldap.startTls -}} +{{- fail "auth.ldap.startTls must be false for an ldaps URL" -}} +{{- end -}} +{{- else if hasPrefix "ldap://" .Values.auth.ldap.url -}} +{{- if not .Values.auth.ldap.startTls -}} +{{- fail "auth.ldap.startTls must be true for an ldap URL" -}} +{{- end -}} +{{- else -}} +{{- fail "auth.ldap.url must start with ldap:// or ldaps://" -}} +{{- end -}} +{{- if regexMatch "[?#]" .Values.auth.ldap.url -}} +{{- fail "auth.ldap.url must not contain a query or fragment" -}} +{{- end -}} +{{- end -}} {{- if .Values.auth.cas.enabled -}} {{- if not .Values.auth.cas.serverUrl -}} {{- fail "auth.cas.enabled=true requires auth.cas.serverUrl" -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index cb456cd9f..fcb4ba21d 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -202,6 +202,23 @@ grep -Fq 'auth-cas-enabled: "true"' "$TMP_DIR/cas.yaml" grep -Fq 'auth-cas-server-url: "https://cas.example.com/cas"' "$TMP_DIR/cas.yaml" grep -Fq 'name: SKILLHUB_AUTH_CAS_SERVICE_URL' "$TMP_DIR/cas.yaml" +render ldap "$CHART_DIR" \ + --set auth.ldap.enabled=true \ + --set auth.ldap.url=ldaps://ldap.example.com:636 \ + --set-string 'auth.ldap.baseDn=dc=example\,dc=com' \ + --set-string 'auth.ldap.bindDn=cn=skillhub\,dc=example\,dc=com' \ + --set-string secrets.ldapBindPassword=ldap-bind-password \ + --show-only templates/configmap.yaml \ + --show-only templates/secret.yaml \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/ldap.yaml" +grep -Fq 'auth-ldap-enabled: "true"' "$TMP_DIR/ldap.yaml" +grep -Fq 'auth-ldap-url: "ldaps://ldap.example.com:636"' "$TMP_DIR/ldap.yaml" +grep -Fq 'ldap-bind-password: "ldap-bind-password"' "$TMP_DIR/ldap.yaml" +grep -Fq 'name: SKILLHUB_AUTH_LDAP_BIND_PASSWORD' "$TMP_DIR/ldap.yaml" +if grep -Fq 'ldap-bind-password:' "$TMP_DIR/default.yaml"; then + fail "disabled LDAP must not render a bind password" +fi + render tls "$CHART_DIR" \ --set ingress.enabled=true \ --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \ @@ -275,6 +292,20 @@ assert_rejected server-off --set server.enabled=false assert_rejected direct-auth-without-provider \ --set auth.direct.enabled=true \ --set-string auth.direct.provider= +assert_rejected ldap-without-url \ + --set auth.ldap.enabled=true \ + --set-string secrets.ldapBindPassword=ldap-bind-password +assert_rejected ldap-without-secret \ + --set auth.ldap.enabled=true \ + --set auth.ldap.url=ldaps://ldap.example.com:636 \ + --set-string 'auth.ldap.baseDn=dc=example\,dc=com' \ + --set-string 'auth.ldap.bindDn=cn=skillhub\,dc=example\,dc=com' +assert_rejected ldap-with-insecure-url \ + --set auth.ldap.enabled=true \ + --set auth.ldap.url=ldap://ldap.example.com:389 \ + --set-string 'auth.ldap.baseDn=dc=example\,dc=com' \ + --set-string 'auth.ldap.bindDn=cn=skillhub\,dc=example\,dc=com' \ + --set-string secrets.ldapBindPassword=ldap-bind-password assert_rejected cas-without-server \ --set auth.cas.enabled=true \ --set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/cas-main/callback diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index b1de1ac65..b7faf293a 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -21,7 +21,7 @@ "auth": { "type": "object", "additionalProperties": false, - "required": ["direct", "accountMerge", "cas"], + "required": ["direct", "accountMerge", "ldap", "cas"], "properties": { "direct": { "type": "object", @@ -41,6 +41,36 @@ "sessionCutoverComplete": { "type": "boolean" } } }, + "ldap": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "providerCode", "displayName", "authority", "url", "startTls", "directoryType", "baseDn", "userSearchBase", "userSearchFilter", "bindDn", "subjectAttribute", "subjectType", "usernameAttribute", "displayNameAttribute", "emailAttribute", "avatarUrlAttribute", "emailAuthoritative", "connectTimeout", "readTimeout", "poolWaitTimeout", "maxConcurrentRequests", "maxAttributeValues"], + "properties": { + "enabled": { "type": "boolean" }, + "providerCode": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 128 }, + "authority": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" }, + "url": { "type": "string" }, + "startTls": { "type": "boolean" }, + "directoryType": { "enum": ["OPENLDAP", "ACTIVE_DIRECTORY", "CUSTOM"] }, + "baseDn": { "type": "string" }, + "userSearchBase": { "type": "string" }, + "userSearchFilter": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "bindDn": { "type": "string" }, + "subjectAttribute": { "type": "string" }, + "subjectType": { "type": "string", "pattern": "^$|^[a-z][a-z0-9_]{0,63}$" }, + "usernameAttribute": { "type": "string" }, + "displayNameAttribute": { "type": "string" }, + "emailAttribute": { "type": "string" }, + "avatarUrlAttribute": { "type": "string" }, + "emailAuthoritative": { "type": "boolean" }, + "connectTimeout": { "type": "string", "pattern": "^P" }, + "readTimeout": { "type": "string", "pattern": "^P" }, + "poolWaitTimeout": { "type": "string", "pattern": "^P" }, + "maxConcurrentRequests": { "type": "integer", "minimum": 1, "maximum": 256 }, + "maxAttributeValues": { "type": "integer", "minimum": 1, "maximum": 64 } + } + }, "cas": { "type": "object", "additionalProperties": false, @@ -200,6 +230,7 @@ "downloadAnonCookieSecret": { "type": "string" }, "oauth2GithubClientId": { "type": "string" }, "oauth2GithubClientSecret": { "type": "string" }, + "ldapBindPassword": { "type": "string" }, "scannerLlmApiKey": { "type": "string" }, "scannerLlmBaseUrl": { "type": "string" }, "scannerLlmModel": { "type": "string" } diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index e699342a0..2401a3104 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -26,6 +26,30 @@ auth: # documented legacy-session transition gate has completed. enabled: false sessionCutoverComplete: false + ldap: + enabled: false + providerCode: ldap-main + displayName: "Corporate Directory" + authority: corp-directory + url: "" + startTls: false + directoryType: OPENLDAP + baseDn: "" + userSearchBase: "" + userSearchFilter: "(uid={0})" + bindDn: "" + subjectAttribute: "" + subjectType: "" + usernameAttribute: uid + displayNameAttribute: displayName + emailAttribute: mail + avatarUrlAttribute: "" + emailAuthoritative: false + connectTimeout: PT5S + readTimeout: PT10S + poolWaitTimeout: PT2S + maxConcurrentRequests: 16 + maxAttributeValues: 16 cas: enabled: false providerCode: cas-main @@ -119,6 +143,7 @@ secrets: downloadAnonCookieSecret: "" oauth2GithubClientId: "" oauth2GithubClientSecret: "" + ldapBindPassword: "" scannerLlmApiKey: "" scannerLlmBaseUrl: "" scannerLlmModel: "" diff --git a/compose.release.yml b/compose.release.yml index abecf52d3..68c29e638 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -93,6 +93,31 @@ services: SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false} SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED: ${SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED:-false} SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE: ${SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE:-false} + SKILLHUB_AUTH_LDAP_ENABLED: ${SKILLHUB_AUTH_LDAP_ENABLED:-false} + SKILLHUB_AUTH_LDAP_PROVIDER_CODE: ${SKILLHUB_AUTH_LDAP_PROVIDER_CODE:-ldap} + SKILLHUB_AUTH_LDAP_DISPLAY_NAME: ${SKILLHUB_AUTH_LDAP_DISPLAY_NAME:-Corporate Directory} + SKILLHUB_AUTH_LDAP_AUTHORITY: ${SKILLHUB_AUTH_LDAP_AUTHORITY:-} + SKILLHUB_AUTH_LDAP_URL: ${SKILLHUB_AUTH_LDAP_URL:-} + SKILLHUB_AUTH_LDAP_START_TLS: ${SKILLHUB_AUTH_LDAP_START_TLS:-false} + SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING: ${SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING:-false} + SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE: ${SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE:-OPENLDAP} + SKILLHUB_AUTH_LDAP_BASE_DN: ${SKILLHUB_AUTH_LDAP_BASE_DN:-} + SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE: ${SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE:-} + SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER: ${SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER:-} + SKILLHUB_AUTH_LDAP_BIND_DN: ${SKILLHUB_AUTH_LDAP_BIND_DN:-} + SKILLHUB_AUTH_LDAP_BIND_PASSWORD: ${SKILLHUB_AUTH_LDAP_BIND_PASSWORD:-} + SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE: ${SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE:-} + SKILLHUB_AUTH_LDAP_SUBJECT_TYPE: ${SKILLHUB_AUTH_LDAP_SUBJECT_TYPE:-} + SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE: ${SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE:-uid} + SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE: ${SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE:-displayName} + SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE: ${SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE:-mail} + SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE: ${SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE:-} + SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE: ${SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE:-false} + SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT: ${SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT:-PT5S} + SKILLHUB_AUTH_LDAP_READ_TIMEOUT: ${SKILLHUB_AUTH_LDAP_READ_TIMEOUT:-PT10S} + SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT: ${SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT:-PT2S} + SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS: ${SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS:-16} + SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES: ${SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES:-16} SKILLHUB_AUTH_CAS_ENABLED: ${SKILLHUB_AUTH_CAS_ENABLED:-false} SKILLHUB_AUTH_CAS_PROVIDER_CODE: ${SKILLHUB_AUTH_CAS_PROVIDER_CODE:-cas} SKILLHUB_AUTH_CAS_DISPLAY_NAME: ${SKILLHUB_AUTH_CAS_DISPLAY_NAME:-CAS} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index b2d5acf67..46e8914dc 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -64,6 +64,7 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| ldap-bind-password | LDAP service account 密码 | 启用 LDAP/AD 时 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | @@ -78,6 +79,11 @@ https:///api/v1/auth/cas//callback 完整字段、身份映射和验证步骤见 [`docs/23-cas-integration.md`](../../docs/23-cas-integration.md)。 +LDAP/Active Directory 默认关闭。启用时修改 `base/configmap.yaml` 中的 +`auth-ldap-*` 字段,并在 `skillhub-secret` 中设置 `ldap-bind-password`。生产环境必须 +使用 LDAPS 或 StartTLS;完整配置、稳定 Subject 映射和验证步骤见 +[`docs/24-ldap-ad-integration.md`](../../docs/24-ldap-ad-integration.md)。 + ### 3. 选择部署方式 **方式一:完整部署(包含 PostgreSQL + Redis)** @@ -247,6 +253,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| ldap-bind-password | LDAP service account 密码 | 启用 LDAP/AD 时 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | LLM 模型名称 | 否 | diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index ad3757dc4..5260dd285 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -193,6 +193,129 @@ spec: name: skillhub-config key: auth-account-merge-session-cutover-complete + # LDAP/Active Directory credential login + - name: SKILLHUB_AUTH_LDAP_ENABLED + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-enabled + - name: SKILLHUB_AUTH_LDAP_PROVIDER_CODE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-provider-code + - name: SKILLHUB_AUTH_LDAP_DISPLAY_NAME + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-display-name + - name: SKILLHUB_AUTH_LDAP_AUTHORITY + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-authority + - name: SKILLHUB_AUTH_LDAP_URL + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-url + - name: SKILLHUB_AUTH_LDAP_START_TLS + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-start-tls + - name: SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-directory-type + - name: SKILLHUB_AUTH_LDAP_BASE_DN + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-base-dn + - name: SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-user-search-base + - name: SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-user-search-filter + - name: SKILLHUB_AUTH_LDAP_BIND_DN + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-bind-dn + - name: SKILLHUB_AUTH_LDAP_BIND_PASSWORD + valueFrom: + secretKeyRef: + name: skillhub-secret + key: ldap-bind-password + optional: true + - name: SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-subject-attribute + - name: SKILLHUB_AUTH_LDAP_SUBJECT_TYPE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-subject-type + - name: SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-username-attribute + - name: SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-display-name-attribute + - name: SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-email-attribute + - name: SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-avatar-url-attribute + - name: SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-email-authoritative + - name: SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-connect-timeout + - name: SKILLHUB_AUTH_LDAP_READ_TIMEOUT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-read-timeout + - name: SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-pool-wait-timeout + - name: SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-max-concurrent-requests + - name: SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES + valueFrom: + configMapKeyRef: + name: skillhub-config + key: auth-ldap-max-attribute-values + # CAS 2.0/3.0 browser login - name: SKILLHUB_AUTH_CAS_ENABLED valueFrom: diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index f2bd6329f..33675024e 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -54,6 +54,33 @@ data: auth-account-merge-enabled: "false" auth-account-merge-session-cutover-complete: "false" + # LDAP/Active Directory 登录(默认关闭) + # 生产环境只使用 LDAPS,或 ldap:// 配合 start-tls=true。provider code、 + # authority 和稳定 subject 映射在产生身份绑定后不得随意修改。 + auth-ldap-enabled: "false" + auth-ldap-provider-code: ldap-main + auth-ldap-display-name: Corporate Directory + auth-ldap-authority: corp-directory + auth-ldap-url: ldaps://ldap.example.com:636 + auth-ldap-start-tls: "false" + auth-ldap-directory-type: OPENLDAP + auth-ldap-base-dn: dc=example,dc=com + auth-ldap-user-search-base: ou=people + auth-ldap-user-search-filter: "(uid={0})" + auth-ldap-bind-dn: cn=skillhub,ou=services,dc=example,dc=com + auth-ldap-subject-attribute: "" + auth-ldap-subject-type: "" + auth-ldap-username-attribute: uid + auth-ldap-display-name-attribute: displayName + auth-ldap-email-attribute: mail + auth-ldap-avatar-url-attribute: "" + auth-ldap-email-authoritative: "false" + auth-ldap-connect-timeout: PT5S + auth-ldap-read-timeout: PT10S + auth-ldap-pool-wait-timeout: PT2S + auth-ldap-max-concurrent-requests: "16" + auth-ldap-max-attribute-values: "16" + # CAS 2.0/3.0 登录(默认关闭) # service-url 必须是浏览器可访问的精确回调地址,且 provider code # 必须与路径中的值一致。 diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index c2b93d45a..731affcb9 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -27,6 +27,9 @@ stringData: oauth2-github-client-id: "" oauth2-github-client-secret: "" + # LDAP service account 密码(启用 LDAP/AD 时必填) + ldap-bind-password: "" + # LLM 配置(可选,用于技能扫描) skill-scanner-llm-api-key: "" skill-scanner-llm-base-url: "" diff --git a/docs/24-ldap-ad-integration.md b/docs/24-ldap-ad-integration.md new file mode 100644 index 000000000..95ee2b6ce --- /dev/null +++ b/docs/24-ldap-ad-integration.md @@ -0,0 +1,180 @@ +# LDAP/Active Directory 接入指南 + +本文说明如何把 OpenLDAP 或 Active Directory 作为 Credential Provider 接入 SkillHub +统一身份核心。LDAP 默认关闭;配置不完整、Authority 冲突或协议定义无效时不会出现在 +登录方法目录中,也不会初始化连接池。 + +## 1. 安全模型 + +LDAP Adapter 只负责在目录中验证凭据并返回外部身份事实: + +```text +service bind/search → 唯一结果 → user DN bind → 稳定 Subject/属性 + → ProviderAuthenticationResult → 统一身份核心 → 平台账号与 Session +``` + +Adapter 不创建或选择平台账号,不通过 email、username、DN 或 display name 静默绑定账号。 +首次登录、重复登录、显式 Identity Link、Account Merge、资料同步和准入策略均由统一身份 +核心处理。 + +生产环境必须使用以下一种传输: + +- `ldaps://`; +- `ldap://` 并启用 StartTLS。 + +客户端使用 JVM 默认 TrustStore 和 hostname verification,不提供生产 trust-all 开关。 +使用企业私有 CA 时,应把 CA 证书加入 SkillHub Server 镜像的 JVM TrustStore。不要把 +bind password、用户密码或 TrustStore password 写入 ConfigMap、镜像层、日志或 Git。 + +## 2. 身份键 + +| 目录类型 | 默认 Subject attribute | 默认 Subject type | 说明 | +|---|---|---|---| +| `OPENLDAP` | `entryUUID` | `ldap_entry_uuid` | 必须是唯一 UUID | +| `ACTIVE_DIRECTORY` | `objectGUID` | `ad_object_guid` | 按 AD 固定 little-endian 字节序转换 | +| `CUSTOM` | 无 | 无 | 两项都必须显式配置 | + +`provider-code`、`authority`、`subject-attribute` 和 `subject-type` 共同决定持久身份绑定。 +产生 Binding 后,endpoint 和证书可以轮换,但不能把相同 Authority 指向另一套目录,也 +不能在普通配置变更中切换 Subject 语义。 + +不要把 `uid`、`sAMAccountName`、DN、mail 或 display name 当作默认 Subject。它们可能 +被管理员修改或复用。 + +## 3. 配置 + +| 环境变量 | 必填 | 默认值 | 说明 | +|---|---:|---|---| +| `SKILLHUB_AUTH_LDAP_ENABLED` | 是 | `false` | 显式启用 Provider | +| `SKILLHUB_AUTH_LDAP_PROVIDER_CODE` | 是 | `ldap` | 稳定 Provider Instance code | +| `SKILLHUB_AUTH_LDAP_DISPLAY_NAME` | 是 | `Corporate Directory` | 登录页展示名 | +| `SKILLHUB_AUTH_LDAP_AUTHORITY` | 是 | 空 | 稳定目录身份域 ID,不是 endpoint | +| `SKILLHUB_AUTH_LDAP_URL` | 是 | 空 | `ldaps://host:636` 或 `ldap://host:389` | +| `SKILLHUB_AUTH_LDAP_START_TLS` | 是 | `false` | `ldap://` 在生产必须设为 `true` | +| `SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE` | 是 | `OPENLDAP` | `OPENLDAP`、`ACTIVE_DIRECTORY`、`CUSTOM` | +| `SKILLHUB_AUTH_LDAP_BASE_DN` | 是 | 空 | 目录搜索根 DN | +| `SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE` | 否 | 空 | 相对于 base DN 的用户搜索 DN | +| `SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER` | 是 | `(uid={0})` | 必须恰好包含一个 `{0}`;用户名会按 LDAP filter 规则 escape | +| `SKILLHUB_AUTH_LDAP_BIND_DN` | 是 | 空 | 只读 service account DN | +| `SKILLHUB_AUTH_LDAP_BIND_PASSWORD` | 是 | 空 | service account 密码,只能来自 Secret | +| `SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE` | CUSTOM 必填 | 目录默认值 | 稳定、唯一且不可变的属性 | +| `SKILLHUB_AUTH_LDAP_SUBJECT_TYPE` | CUSTOM 必填 | 目录默认值 | 统一身份 Subject type | +| `SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE` | 否 | `uid` | 平台 username 候选属性;可设为空禁用 | +| `SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE` | 否 | `displayName` | display name 候选属性 | +| `SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE` | 否 | `mail` | email 候选属性 | +| `SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE` | 否 | 空 | avatar URL 候选属性 | +| `SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE` | 否 | `false` | 仅在目录确为企业权威 email 源时启用 | +| `SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT` | 否 | `PT5S` | TCP/TLS 连接超时,最大 1 分钟 | +| `SKILLHUB_AUTH_LDAP_READ_TIMEOUT` | 否 | `PT10S` | LDAP 响应超时,最大 1 分钟 | +| `SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT` | 否 | `PT2S` | 连接池等待超时,最大 1 分钟 | +| `SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS` | 否 | `16` | 连接池上限,1–256 | +| `SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES` | 否 | `16` | 单个映射属性的值数量上限,1–64 | + +要在官方 Web 登录页显示 LDAP 用户名/密码表单,还需要: + +```dotenv +SKILLHUB_AUTH_DIRECT_ENABLED=true +SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true +SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=ldap-main +``` + +LDAP Provider 即使不作为默认 Web Direct Provider,也可以用于显式 Identity Link 和 +Account Merge 的 credential verification。 + +### Docker Compose + +复制 `.env.release.example` 为 `.env.release`,填写 LDAP 字段并运行: + +```bash +./scripts/validate-release-config.sh .env.release +docker compose --env-file .env.release -f compose.release.yml up -d +``` + +`.env.release` 已被 Git 忽略。不要把真实 bind password 复制回示例文件或提交记录。 + +### Helm + +```yaml +auth: + direct: + enabled: true + provider: ldap-main + ldap: + enabled: true + providerCode: ldap-main + displayName: Corporate Directory + authority: corp-directory + url: ldaps://ldap.example.com:636 + startTls: false + directoryType: OPENLDAP + baseDn: dc=example,dc=com + userSearchBase: ou=people + userSearchFilter: "(uid={0})" + bindDn: cn=skillhub,ou=services,dc=example,dc=com + subjectAttribute: "" + subjectType: "" + usernameAttribute: uid + displayNameAttribute: displayName + emailAttribute: mail + avatarUrlAttribute: "" + emailAuthoritative: false + connectTimeout: PT5S + readTimeout: PT10S + poolWaitTimeout: PT2S + maxConcurrentRequests: 16 + maxAttributeValues: 16 +secrets: + ldapBindPassword: +``` + +生产环境建议设置 `existingSecret`,并在该 Secret 中提供固定 key +`ldap-bind-password`。Chart 在渲染阶段拒绝缺失 bind password、明文 `ldap://`、 +LDAPS 与 StartTLS 冲突以及不完整 CUSTOM Subject 映射。 + +### Kustomize + +1. 修改 `deploy/k8s/base/configmap.yaml` 中的 `auth-ldap-*` 字段。 +2. 在部署环境的 `skillhub-secret` 中设置 `ldap-bind-password`。 +3. 保证 Server Pod 能解析并访问 LDAP endpoint,且 JVM TrustStore 信任目录证书。 +4. 应用对应 overlay。 + +升级旧 Secret 时 LDAP 默认关闭,缺失 `ldap-bind-password` 不会阻止旧部署启动;一旦 +启用 LDAP,必须先添加该 key,否则 Provider 会保持隐藏。 + +## 4. Profile 与 email 策略 + +普通 LDAP `mail` 固定按 `PROVIDER_ASSERTED` 输入,不满足 verified email 准入,也不会 +触发隐式账号绑定。只有管理员确认该目录是企业权威 email 源并设置 +`email-authoritative=true` 时,统一身份核心才会在 Provider 的 assurance 上限内提升为 +`AUTHORITATIVE`。 + +display name、email 和 avatar 的覆盖行为继续由统一身份核心的 Provider profile policy +控制。用户名变化不会改变 `entryUUID`/`objectGUID` Binding,也不会创建第二个账号。 + +## 5. 验证清单 + +至少验证以下行为: + +1. LDAP 关闭或配置不完整时,`/api/v1/auth/methods` 不显示该 Provider,目录没有连接。 +2. 正确凭据首次登录创建或进入策略指定的平台账号;重复登录命中同一 userId。 +3. 修改 LDAP username 后,使用新 username 登录仍命中相同 `entryUUID`/`objectGUID`。 +4. 错误密码与未知账号都向客户端返回通用认证失败,不泄露账号是否存在。 +5. 搜索多结果、缺失/多值 Subject、非法 `entryUUID` 和过大属性均 fail closed。 +6. 包含 `*`、`(`、`)`、`\\` 和 NUL 的 username 不能改变搜索 filter 语义。 +7. LDAPS 和 StartTLS 使用受信证书成功;不可信证书或 hostname 不匹配失败。 +8. LDAP 不可用、连接超时、读取超时和连接池耗尽返回稳定安全错误。 +9. email collision 返回统一的显式链接要求,不复用或接管已有账号。 +10. Identity Link 与 Account Merge 需要 fresh credential verification,成功 intent 不能重放。 +11. 日志、审计、指标和异常中不包含用户密码、bind password、完整 LDAP 响应或 filter 输入。 + +低基数指标名为 `skillhub.auth.ldap`,标签只包含 `provider`、`transport` 和 `result`。 + +## 6. 升级与回滚 + +- 新 Provider 和全部配置默认关闭,不修改数据库 schema、Spring Session 序列化或现有 + GitHub/GitLab/OIDC/CAS/本地密码行为。 +- 从旧版本升级时,未设置 LDAP 环境变量的部署保持原行为。 +- 已产生 LDAP Binding 后回滚旧版本会暂时失去 LDAP 登录入口,但不会删除账号、Binding + 或业务数据;重新升级并恢复相同 provider code、authority 和 Subject 映射后可继续使用。 +- endpoint、证书和 service account 可以轮换;Authority 或 Subject 语义变化必须执行 + 显式迁移,不能通过覆盖配置完成。 diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index bee780fbc..4914a26db 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -150,6 +150,42 @@ write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes- printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env" expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false" +valid_ldaps_env="$tmp/valid-ldaps.env" +write_env "$valid_ldaps_env" "release-download-secret-32-bytes-minimum" +cat >>"$valid_ldaps_env" <<'EOF' +SKILLHUB_AUTH_LDAP_ENABLED=true +SKILLHUB_AUTH_LDAP_PROVIDER_CODE=ldap-main +SKILLHUB_AUTH_LDAP_DISPLAY_NAME=Corporate Directory +SKILLHUB_AUTH_LDAP_AUTHORITY=corp-directory +SKILLHUB_AUTH_LDAP_URL=ldaps://ldap.example.com:636 +SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE=OPENLDAP +SKILLHUB_AUTH_LDAP_BASE_DN=dc=example,dc=com +SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER=(uid={0}) +SKILLHUB_AUTH_LDAP_BIND_DN=cn=skillhub,dc=example,dc=com +SKILLHUB_AUTH_LDAP_BIND_PASSWORD=release-ldap-bind-password +EOF +"$SCRIPT" "$valid_ldaps_env" >/dev/null + +valid_starttls_env="$tmp/valid-starttls.env" +cp "$valid_ldaps_env" "$valid_starttls_env" +sed -i 's|ldaps://ldap.example.com:636|ldap://ldap.example.com:389|' "$valid_starttls_env" +printf '%s\n' "SKILLHUB_AUTH_LDAP_START_TLS=true" >>"$valid_starttls_env" +"$SCRIPT" "$valid_starttls_env" >/dev/null + +insecure_ldap_env="$tmp/insecure-ldap.env" +cp "$valid_ldaps_env" "$insecure_ldap_env" +sed -i 's|ldaps://ldap.example.com:636|ldap://ldap.example.com:389|' "$insecure_ldap_env" +expect_fail "$insecure_ldap_env" "SKILLHUB_AUTH_LDAP_START_TLS must be true for an ldap URL" + +missing_ldap_secret_env="$tmp/missing-ldap-secret.env" +grep -v '^SKILLHUB_AUTH_LDAP_BIND_PASSWORD=' "$valid_ldaps_env" >"$missing_ldap_secret_env" +expect_fail "$missing_ldap_secret_env" "SKILLHUB_AUTH_LDAP_BIND_PASSWORD is required" + +custom_ldap_without_subject_env="$tmp/custom-ldap-without-subject.env" +cp "$valid_ldaps_env" "$custom_ldap_without_subject_env" +printf '%s\n' "SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE=CUSTOM" >>"$custom_ldap_without_subject_env" +expect_fail "$custom_ldap_without_subject_env" "SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE is required" + draft_env="$tmp/draft.env" while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 780602e39..840c73eb4 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -222,11 +222,53 @@ validate_boolean BOOTSTRAP_ADMIN_ENABLED validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET +validate_boolean SKILLHUB_AUTH_LDAP_ENABLED +validate_boolean SKILLHUB_AUTH_LDAP_START_TLS +validate_boolean SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING +validate_boolean SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE validate_boolean SKILLHUB_AUTH_CAS_ENABLED validate_boolean SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING validate_boolean SPRING_DATA_REDIS_SSL_ENABLED validate_boolean SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST +if [ "${SKILLHUB_AUTH_LDAP_ENABLED:-false}" = "true" ]; then + require_non_empty SKILLHUB_AUTH_LDAP_PROVIDER_CODE + require_non_empty SKILLHUB_AUTH_LDAP_DISPLAY_NAME + require_non_empty SKILLHUB_AUTH_LDAP_AUTHORITY + require_non_empty SKILLHUB_AUTH_LDAP_URL + require_non_empty SKILLHUB_AUTH_LDAP_BASE_DN + require_non_empty SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER + require_non_empty SKILLHUB_AUTH_LDAP_BIND_DN + require_non_empty SKILLHUB_AUTH_LDAP_BIND_PASSWORD + case "${SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE:-OPENLDAP}" in + OPENLDAP|ACTIVE_DIRECTORY|CUSTOM) ;; + *) error "SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE must be OPENLDAP, ACTIVE_DIRECTORY, or CUSTOM" ;; + esac + if [ "${SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE:-OPENLDAP}" = "CUSTOM" ]; then + require_non_empty SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE + require_non_empty SKILLHUB_AUTH_LDAP_SUBJECT_TYPE + fi + case "${SKILLHUB_AUTH_LDAP_URL:-}" in + ldaps://*) + if [ "${SKILLHUB_AUTH_LDAP_START_TLS:-false}" = "true" ]; then + error "SKILLHUB_AUTH_LDAP_START_TLS must be false for an ldaps URL" + fi + ;; + ldap://*) + if [ "${SKILLHUB_AUTH_LDAP_START_TLS:-false}" != "true" ]; then + error "SKILLHUB_AUTH_LDAP_START_TLS must be true for an ldap URL in release deployments" + fi + ;; + *) error "SKILLHUB_AUTH_LDAP_URL must start with ldap:// or ldaps://" ;; + esac + case "${SKILLHUB_AUTH_LDAP_URL:-}" in + *\?*|*\#*) error "SKILLHUB_AUTH_LDAP_URL must not contain a query or fragment" ;; + esac + if [ "${SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING:-false}" = "true" ]; then + error "SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING cannot be true in release deployments" + fi +fi + if [ "${SKILLHUB_AUTH_CAS_ENABLED:-false}" = "true" ]; then require_non_empty SKILLHUB_AUTH_CAS_PROVIDER_CODE require_non_empty SKILLHUB_AUTH_CAS_DISPLAY_NAME diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java index c4689c6d7..eea4506c0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java @@ -648,6 +648,7 @@ private CasLoginFlowException mapProviderFailure( exception.getReasonCode(); return switch (code) { case UPSTREAM_INVALID_CREDENTIALS, + UPSTREAM_IDENTITY_NOT_FOUND, REPLAY_DETECTED -> failure(CasLoginFailure.VALIDATION_FAILED); case UPSTREAM_ACCESS_DENIED -> diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java index 8fcacdceb..79c7a7a7e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java @@ -21,6 +21,7 @@ static AuthFlowException map( ProviderAuthenticationException exception) { return switch (exception.getReasonCode()) { case UPSTREAM_INVALID_CREDENTIALS, + UPSTREAM_IDENTITY_NOT_FOUND, REPLAY_DETECTED -> failure( HttpStatus.UNAUTHORIZED, "error.auth.external.invalidAssertion"); @@ -41,6 +42,7 @@ static IdentityLinkException mapIdentityLink( IdentityLinkFailureCode reasonCode = switch (exception.getReasonCode()) { case UPSTREAM_INVALID_CREDENTIALS, + UPSTREAM_IDENTITY_NOT_FOUND, UPSTREAM_ACCESS_DENIED, REPLAY_DETECTED -> IdentityLinkFailureCode @@ -59,6 +61,7 @@ static AccountMergeException mapAccountMerge( AccountMergeFailureCode reasonCode = switch (exception.getReasonCode()) { case UPSTREAM_INVALID_CREDENTIALS, + UPSTREAM_IDENTITY_NOT_FOUND, UPSTREAM_ACCESS_DENIED, REPLAY_DETECTED -> AccountMergeFailureCode diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index feb3af659..5dd366c03 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -121,6 +121,32 @@ skillhub: providers: {} direct: enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} + ldap: + enabled: ${SKILLHUB_AUTH_LDAP_ENABLED:false} + provider-code: ${SKILLHUB_AUTH_LDAP_PROVIDER_CODE:ldap} + display-name: ${SKILLHUB_AUTH_LDAP_DISPLAY_NAME:Corporate Directory} + authority: ${SKILLHUB_AUTH_LDAP_AUTHORITY:} + url: ${SKILLHUB_AUTH_LDAP_URL:} + start-tls: ${SKILLHUB_AUTH_LDAP_START_TLS:false} + allow-insecure-for-testing: ${SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING:false} + directory-type: ${SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE:OPENLDAP} + base-dn: ${SKILLHUB_AUTH_LDAP_BASE_DN:} + user-search-base: ${SKILLHUB_AUTH_LDAP_USER_SEARCH_BASE:} + user-search-filter: ${SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER:(uid={0})} + bind-dn: ${SKILLHUB_AUTH_LDAP_BIND_DN:} + bind-password: ${SKILLHUB_AUTH_LDAP_BIND_PASSWORD:} + subject-attribute: ${SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE:} + subject-type: ${SKILLHUB_AUTH_LDAP_SUBJECT_TYPE:} + username-attribute: ${SKILLHUB_AUTH_LDAP_USERNAME_ATTRIBUTE:uid} + display-name-attribute: ${SKILLHUB_AUTH_LDAP_DISPLAY_NAME_ATTRIBUTE:displayName} + email-attribute: ${SKILLHUB_AUTH_LDAP_EMAIL_ATTRIBUTE:mail} + avatar-url-attribute: ${SKILLHUB_AUTH_LDAP_AVATAR_URL_ATTRIBUTE:} + email-authoritative: ${SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE:false} + connect-timeout: ${SKILLHUB_AUTH_LDAP_CONNECT_TIMEOUT:PT5S} + read-timeout: ${SKILLHUB_AUTH_LDAP_READ_TIMEOUT:PT10S} + pool-wait-timeout: ${SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT:PT2S} + max-concurrent-requests: ${SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS:16} + max-attribute-values: ${SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES:16} session-bootstrap: enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false} cas: diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 83f595305..75e401f4c 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -23,6 +23,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + com.unboundid + unboundid-ldapsdk + org.springframework.boot spring-boot-starter-web 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 e49dd74b2..68ff34060 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 @@ -125,7 +125,7 @@ private ExternalProfile createProfile( .filter(value -> !value.value().isBlank()) .map(value -> new EmailClaim( value.value(), - toEmailAssurance(value.trust()) + emailAssurance(descriptor, value) .clampTo(descriptor.emailAssuranceLimit()))); Optional avatarUrl = firstValue( @@ -202,6 +202,16 @@ private EmailAssurance toEmailAssurance(ProviderAttributeTrust trust) { }; } + private EmailAssurance emailAssurance( + ProviderDescriptor descriptor, + ProviderAttributeValue value) { + if (descriptor.authoritativeEmailSource() + && value.trust() == ProviderAttributeTrust.ASSERTED) { + return EmailAssurance.AUTHORITATIVE; + } + return toEmailAssurance(value.trust()); + } + private IdentityCoreException invalidAssertion() { return new IdentityCoreException( IdentityFailureCode.INVALID_IDENTITY_ASSERTION); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java index 34c96aa70..84a9fe362 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java @@ -17,6 +17,7 @@ record ProviderDescriptor( List emailAttributes, List avatarAttributes, EmailAssurance emailAssuranceLimit, + boolean authoritativeEmailSource, ProvisioningMode provisioningMode, ProfileSyncPolicy profileSyncPolicy ) { @@ -69,6 +70,37 @@ record ProviderDescriptor( avatarAttributes = List.copyOf(avatarAttributes); } + ProviderDescriptor( + String providerCode, + String protocol, + String canonicalAuthority, + String displayName, + String primarySubjectType, + String legacyPrimarySubjectType, + Map subjectCanonicalizers, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes, + EmailAssurance emailAssuranceLimit, + ProvisioningMode provisioningMode, + ProfileSyncPolicy profileSyncPolicy) { + this( + providerCode, + protocol, + canonicalAuthority, + displayName, + primarySubjectType, + legacyPrimarySubjectType, + subjectCanonicalizers, + displayNameAttributes, + emailAttributes, + avatarAttributes, + emailAssuranceLimit, + false, + provisioningMode, + profileSyncPolicy); + } + ProviderDescriptor( String providerCode, String protocol, @@ -93,6 +125,7 @@ record ProviderDescriptor( emailAttributes, avatarAttributes, emailAssuranceLimit, + false, ProvisioningMode.AUTO, ProfileSyncPolicy.defaults()); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java index 99db2dbd2..7d98edeb3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java @@ -274,6 +274,7 @@ private ProviderDescriptor descriptorFrom( definition.emailAttributes(), definition.avatarAttributes(), definition.emailAssuranceLimit(), + definition.authoritativeEmailSource(), policy.provisioningMode(), policy.profileSyncPolicy()); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticatedEntry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticatedEntry.java new file mode 100644 index 000000000..f45d469f0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticatedEntry.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.auth.ldap; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Bounded, non-secret directory facts returned after a successful user bind. + */ +record LdapAuthenticatedEntry( + String subject, + Map> attributes, + Instant authenticatedAt +) { + LdapAuthenticatedEntry { + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(attributes, "attributes"); + Objects.requireNonNull(authenticatedAt, "authenticatedAt"); + if (subject.isBlank()) { + throw new IllegalArgumentException( + "LDAP subject must not be blank"); + } + LinkedHashMap> copied = + new LinkedHashMap<>(); + attributes.forEach((key, values) -> { + Objects.requireNonNull(key, "LDAP attribute key"); + Objects.requireNonNull(values, "LDAP attribute values"); + List copiedValues = List.copyOf(values); + if (copiedValues.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException( + "LDAP attribute values must not contain null"); + } + copied.put(key, copiedValues); + }); + attributes = Map.copyOf(copied); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapter.java new file mode 100644 index 000000000..0eb019792 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapter.java @@ -0,0 +1,217 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import com.iflytek.skillhub.auth.provider.ProviderInstanceDefinition; +import com.iflytek.skillhub.auth.provider.SubjectNormalization; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Maps a verified LDAP bind into provider facts consumed by the unified + * identity core. + */ +@Component +public final class LdapAuthenticationAdapter + implements CredentialAuthenticationAdapter { + + static final String USERNAME_ATTRIBUTE = "ldap_username"; + static final String DISPLAY_NAME_ATTRIBUTE = "ldap_display_name"; + static final String EMAIL_ATTRIBUTE = "ldap_email"; + static final String AVATAR_URL_ATTRIBUTE = "ldap_avatar_url"; + private static final int MAX_CREDENTIAL_LENGTH = 4096; + private static final int MAX_ATTRIBUTE_VALUE_LENGTH = 8192; + + private final LdapProviderConfiguration configuration; + private final LdapDirectoryClient directoryClient; + private final LdapAuthenticationMetrics metrics; + + @Autowired + LdapAuthenticationAdapter( + LdapProviderConfiguration configuration, + LdapDirectoryClient directoryClient, + LdapAuthenticationMetrics metrics) { + this.configuration = configuration; + this.directoryClient = directoryClient; + this.metrics = metrics; + } + + LdapAuthenticationAdapter( + LdapProviderConfiguration configuration, + LdapDirectoryClient directoryClient) { + this( + configuration, + directoryClient, + LdapAuthenticationMetrics.noop()); + } + + @Override + public ProviderInstanceDefinition provider() { + if (!configuration.enabled()) { + return new ProviderInstanceDefinition( + "ldap", + "ldap", + "disabled", + "Corporate Directory", + "ldap_entry_uuid", + "ldap_entry_uuid", + Map.of( + "ldap_entry_uuid", + SubjectNormalization.EXACT), + List.of( + DISPLAY_NAME_ATTRIBUTE, + USERNAME_ATTRIBUTE), + List.of(EMAIL_ATTRIBUTE), + List.of(AVATAR_URL_ATTRIBUTE), + EmailAssurance.PROVIDER_ASSERTED, + false); + } + LdapProviderConfiguration.ResolvedLdapProvider resolved = + configuration.requireResolved(); + return new ProviderInstanceDefinition( + resolved.providerCode(), + "ldap", + resolved.authority(), + resolved.displayName(), + resolved.subjectType(), + resolved.subjectType(), + Map.of( + resolved.subjectType(), + SubjectNormalization.EXACT), + List.of( + DISPLAY_NAME_ATTRIBUTE, + USERNAME_ATTRIBUTE), + List.of(EMAIL_ATTRIBUTE), + List.of(AVATAR_URL_ATTRIBUTE), + resolved.emailAuthoritative() + ? EmailAssurance.AUTHORITATIVE + : EmailAssurance.PROVIDER_ASSERTED, + resolved.emailAuthoritative(), + true); + } + + @Override + public ProviderAuthenticationResult authenticate( + CredentialAuthenticationRequest request) { + requireCredential(request == null ? null : request.username()); + requireCredential(request == null ? null : request.password()); + LdapProviderConfiguration.ResolvedLdapProvider resolved; + try { + resolved = configuration.requireResolved(); + } catch (RuntimeException exception) { + throw new ProviderAuthenticationException( + ProviderAuthenticationFailureCode + .UPSTREAM_MISCONFIGURED); + } + LdapAuthenticatedEntry entry; + try { + entry = directoryClient.authenticate(resolved, request); + metrics.recordSuccess( + resolved.providerCode(), + resolved.transport()); + } catch (ProviderAuthenticationException exception) { + metrics.recordFailure( + resolved.providerCode(), + resolved.transport(), + exception.getReasonCode()); + throw exception; + } + + Map> attributes = + new LinkedHashMap<>(); + putMapped( + attributes, + USERNAME_ATTRIBUTE, + resolved.usernameAttribute(), + entry.attributes(), + ProviderAttributeTrust.ASSERTED); + putMapped( + attributes, + DISPLAY_NAME_ATTRIBUTE, + resolved.displayNameAttribute(), + entry.attributes(), + ProviderAttributeTrust.ASSERTED); + putMapped( + attributes, + EMAIL_ATTRIBUTE, + resolved.emailAttribute(), + entry.attributes(), + ProviderAttributeTrust.ASSERTED); + putMapped( + attributes, + AVATAR_URL_ATTRIBUTE, + resolved.avatarUrlAttribute(), + entry.attributes(), + ProviderAttributeTrust.ASSERTED); + + return new ProviderAuthenticationResult( + new SubjectCandidate( + resolved.subjectType(), + entry.subject()), + List.of(), + attributes, + new ProtocolAuthenticationEvidence( + "ldap", + entry.authenticatedAt(), + authenticationMethods(resolved.transport()))); + } + + private Set authenticationMethods( + LdapTransport transport) { + return switch (transport) { + case PLAIN -> Set.of("password", "ldap"); + case STARTTLS -> Set.of("password", "starttls"); + case LDAPS -> Set.of("password", "ldaps"); + }; + } + + private void putMapped( + Map> target, + String targetKey, + Optional sourceKey, + Map> source, + ProviderAttributeTrust trust) { + if (sourceKey.isEmpty()) { + return; + } + List values = source.get(sourceKey.orElseThrow()); + if (values == null) { + return; + } + List mapped = new ArrayList<>(); + for (String value : values) { + if (!value.isBlank() + && value.length() <= MAX_ATTRIBUTE_VALUE_LENGTH) { + mapped.add(new ProviderAttributeValue(value, trust)); + } + } + if (!mapped.isEmpty()) { + target.put(targetKey, List.copyOf(mapped)); + } + } + + private void requireCredential(String value) { + if (value == null + || value.isBlank() + || value.length() > MAX_CREDENTIAL_LENGTH + || value.chars().anyMatch(Character::isISOControl)) { + throw new ProviderAuthenticationException( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationMetrics.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationMetrics.java new file mode 100644 index 000000000..5ee3d2230 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationMetrics.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Locale; +import org.springframework.stereotype.Component; + +/** + * Low-cardinality protocol metrics for LDAP authentication attempts. + */ +@Component +final class LdapAuthenticationMetrics { + + private final MeterRegistry meterRegistry; + + LdapAuthenticationMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + void recordSuccess( + String providerCode, + LdapTransport transport) { + record(providerCode, transport, "success"); + } + + void recordFailure( + String providerCode, + LdapTransport transport, + ProviderAuthenticationFailureCode failureCode) { + record( + providerCode, + transport, + failureCode.name().toLowerCase(Locale.ROOT)); + } + + private void record( + String providerCode, + LdapTransport transport, + String result) { + if (meterRegistry == null) { + return; + } + meterRegistry.counter( + "skillhub.auth.ldap", + "provider", + providerCode, + "transport", + transport.name().toLowerCase(Locale.ROOT), + "result", + result).increment(); + } + + static LdapAuthenticationMetrics noop() { + return new LdapAuthenticationMetrics(null); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryClient.java new file mode 100644 index 000000000..e6fb23b13 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryClient.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; + +/** + * Performs LDAP protocol I/O before the platform identity transaction starts. + */ +@FunctionalInterface +interface LdapDirectoryClient { + + LdapAuthenticatedEntry authenticate( + LdapProviderConfiguration.ResolvedLdapProvider provider, + CredentialAuthenticationRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryType.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryType.java new file mode 100644 index 000000000..896262a95 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapDirectoryType.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.auth.ldap; + +enum LdapDirectoryType { + OPENLDAP, + ACTIVE_DIRECTORY, + CUSTOM +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProperties.java new file mode 100644 index 000000000..9da9625d8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProperties.java @@ -0,0 +1,239 @@ +package com.iflytek.skillhub.auth.ldap; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Static configuration for the built-in LDAP/Active Directory provider. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.auth.ldap") +public class LdapProperties { + + private boolean enabled; + private String providerCode = "ldap"; + private String displayName = "Corporate Directory"; + private String authority; + private String url; + private boolean startTls; + private boolean allowInsecureForTesting; + private String directoryType = "OPENLDAP"; + private String baseDn; + private String userSearchBase = ""; + private String userSearchFilter = "(uid={0})"; + private String bindDn; + private String bindPassword; + private String subjectAttribute; + private String subjectType; + private String usernameAttribute = "uid"; + private String displayNameAttribute = "displayName"; + private String emailAttribute = "mail"; + private String avatarUrlAttribute; + private boolean emailAuthoritative; + private Duration connectTimeout = Duration.ofSeconds(5); + private Duration readTimeout = Duration.ofSeconds(10); + private Duration poolWaitTimeout = Duration.ofSeconds(2); + private int maxConcurrentRequests = 16; + private int maxAttributeValues = 16; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getProviderCode() { + return providerCode; + } + + public void setProviderCode(String providerCode) { + this.providerCode = providerCode; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getAuthority() { + return authority; + } + + public void setAuthority(String authority) { + this.authority = authority; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public boolean isStartTls() { + return startTls; + } + + public void setStartTls(boolean startTls) { + this.startTls = startTls; + } + + public boolean isAllowInsecureForTesting() { + return allowInsecureForTesting; + } + + public void setAllowInsecureForTesting(boolean allowInsecureForTesting) { + this.allowInsecureForTesting = allowInsecureForTesting; + } + + public String getDirectoryType() { + return directoryType; + } + + public void setDirectoryType(String directoryType) { + this.directoryType = directoryType; + } + + public String getBaseDn() { + return baseDn; + } + + public void setBaseDn(String baseDn) { + this.baseDn = baseDn; + } + + public String getUserSearchBase() { + return userSearchBase; + } + + public void setUserSearchBase(String userSearchBase) { + this.userSearchBase = userSearchBase; + } + + public String getUserSearchFilter() { + return userSearchFilter; + } + + public void setUserSearchFilter(String userSearchFilter) { + this.userSearchFilter = userSearchFilter; + } + + public String getBindDn() { + return bindDn; + } + + public void setBindDn(String bindDn) { + this.bindDn = bindDn; + } + + public String getBindPassword() { + return bindPassword; + } + + public void setBindPassword(String bindPassword) { + this.bindPassword = bindPassword; + } + + public String getSubjectAttribute() { + return subjectAttribute; + } + + public void setSubjectAttribute(String subjectAttribute) { + this.subjectAttribute = subjectAttribute; + } + + public String getSubjectType() { + return subjectType; + } + + public void setSubjectType(String subjectType) { + this.subjectType = subjectType; + } + + public String getUsernameAttribute() { + return usernameAttribute; + } + + public void setUsernameAttribute(String usernameAttribute) { + this.usernameAttribute = usernameAttribute; + } + + public String getDisplayNameAttribute() { + return displayNameAttribute; + } + + public void setDisplayNameAttribute(String displayNameAttribute) { + this.displayNameAttribute = displayNameAttribute; + } + + public String getEmailAttribute() { + return emailAttribute; + } + + public void setEmailAttribute(String emailAttribute) { + this.emailAttribute = emailAttribute; + } + + public String getAvatarUrlAttribute() { + return avatarUrlAttribute; + } + + public void setAvatarUrlAttribute(String avatarUrlAttribute) { + this.avatarUrlAttribute = avatarUrlAttribute; + } + + public boolean isEmailAuthoritative() { + return emailAuthoritative; + } + + public void setEmailAuthoritative(boolean emailAuthoritative) { + this.emailAuthoritative = emailAuthoritative; + } + + public Duration getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public Duration getReadTimeout() { + return readTimeout; + } + + public void setReadTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + } + + public Duration getPoolWaitTimeout() { + return poolWaitTimeout; + } + + public void setPoolWaitTimeout(Duration poolWaitTimeout) { + this.poolWaitTimeout = poolWaitTimeout; + } + + public int getMaxConcurrentRequests() { + return maxConcurrentRequests; + } + + public void setMaxConcurrentRequests(int maxConcurrentRequests) { + this.maxConcurrentRequests = maxConcurrentRequests; + } + + public int getMaxAttributeValues() { + return maxAttributeValues; + } + + public void setMaxAttributeValues(int maxAttributeValues) { + this.maxAttributeValues = maxAttributeValues; + } +} 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 new file mode 100644 index 000000000..1a5e0d100 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfiguration.java @@ -0,0 +1,507 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.unboundid.ldap.sdk.DN; +import com.unboundid.ldap.sdk.Filter; +import com.unboundid.ldap.sdk.LDAPException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Component; + +/** + * Fail-closed resolver for the trusted LDAP provider configuration. + */ +@Component +final class LdapProviderConfiguration { + + private static final Pattern PROVIDER_CODE_PATTERN = + Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); + private static final Pattern AUTHORITY_PATTERN = + Pattern.compile("[a-z0-9][a-z0-9._:-]{0,127}"); + private static final Pattern SUBJECT_TYPE_PATTERN = + Pattern.compile("[a-z][a-z0-9_]{0,63}"); + private static final Pattern ATTRIBUTE_PATTERN = + Pattern.compile("[A-Za-z][A-Za-z0-9_.;:-]{0,127}"); + private static final Duration MAX_NETWORK_TIMEOUT = + Duration.ofMinutes(1); + 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 final LdapProperties properties; + private final Environment environment; + + LdapProviderConfiguration( + LdapProperties properties, + Environment environment) { + this.properties = properties; + this.environment = environment; + } + + boolean enabled() { + return properties.isEnabled(); + } + + ResolvedLdapProvider requireResolved() { + if (!properties.isEnabled()) { + throw invalidConfiguration(); + } + + String providerCode = requireMatching( + properties.getProviderCode(), + PROVIDER_CODE_PATTERN); + String displayName = requireText( + properties.getDisplayName(), + 128); + String authority = requireMatching( + properties.getAuthority(), + AUTHORITY_PATTERN); + URI endpoint = requireEndpoint(properties.getUrl()); + LdapTransport transport = resolveTransport(endpoint); + LdapDirectoryType directoryType = parseDirectoryType( + properties.getDirectoryType()); + String baseDn = requireDn(properties.getBaseDn(), false); + String userSearchBase = requireDn( + properties.getUserSearchBase(), + true); + String userSearchFilter = requireSearchFilter( + properties.getUserSearchFilter()); + String bindDn = requireDn(properties.getBindDn(), false); + String bindPassword = requireSecret( + properties.getBindPassword()); + + String subjectAttribute = optionalAttribute( + properties.getSubjectAttribute()) + .orElseGet(() -> defaultSubjectAttribute(directoryType)); + String subjectType = optionalSubjectType( + properties.getSubjectType()) + .orElseGet(() -> defaultSubjectType(directoryType)); + Optional usernameAttribute = optionalAttribute( + properties.getUsernameAttribute()); + Optional displayNameAttribute = optionalAttribute( + properties.getDisplayNameAttribute()); + Optional emailAttribute = optionalAttribute( + properties.getEmailAttribute()); + Optional avatarUrlAttribute = optionalAttribute( + properties.getAvatarUrlAttribute()); + + Duration connectTimeout = requireDuration( + properties.getConnectTimeout()); + Duration readTimeout = requireDuration( + properties.getReadTimeout()); + Duration poolWaitTimeout = requireDuration( + properties.getPoolWaitTimeout()); + int maximumConcurrentRequests = + properties.getMaxConcurrentRequests(); + if (maximumConcurrentRequests < 1 + || maximumConcurrentRequests > 256) { + throw invalidConfiguration(); + } + int maximumAttributeValues = properties.getMaxAttributeValues(); + if (maximumAttributeValues < 1 + || maximumAttributeValues > 64) { + throw invalidConfiguration(); + } + + Set requestedAttributes = new LinkedHashSet<>(); + requestedAttributes.add(subjectAttribute); + usernameAttribute.ifPresent(requestedAttributes::add); + displayNameAttribute.ifPresent(requestedAttributes::add); + emailAttribute.ifPresent(requestedAttributes::add); + avatarUrlAttribute.ifPresent(requestedAttributes::add); + + return new ResolvedLdapProvider( + providerCode, + displayName, + authority, + endpoint, + transport, + directoryType, + baseDn, + userSearchBase, + userSearchFilter, + bindDn, + bindPassword, + subjectAttribute, + subjectType, + usernameAttribute, + displayNameAttribute, + emailAttribute, + avatarUrlAttribute, + properties.isEmailAuthoritative(), + connectTimeout, + readTimeout, + poolWaitTimeout, + maximumConcurrentRequests, + maximumAttributeValues, + Set.copyOf(requestedAttributes)); + } + + private URI requireEndpoint(String value) { + if (value == null || value.isBlank() + || !value.equals(value.strip())) { + throw invalidConfiguration(); + } + URI uri; + try { + uri = new URI(value); + } catch (URISyntaxException exception) { + throw invalidConfiguration(); + } + if (!uri.isAbsolute() + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || uri.getPort() == 0 + || uri.getPort() > 65535 + || !(uri.getPath().isEmpty() + || "/".equals(uri.getPath()))) { + throw invalidConfiguration(); + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"ldap".equals(scheme) && !"ldaps".equals(scheme)) { + throw invalidConfiguration(); + } + return uri; + } + + private LdapTransport resolveTransport(URI endpoint) { + if ("ldaps".equalsIgnoreCase(endpoint.getScheme())) { + if (properties.isStartTls()) { + throw invalidConfiguration(); + } + return LdapTransport.LDAPS; + } + if (properties.isStartTls()) { + return LdapTransport.STARTTLS; + } + if (!properties.isAllowInsecureForTesting() + || !environment.acceptsProfiles(Profiles.of( + "local", + "test", + "staging"))) { + throw invalidConfiguration(); + } + return LdapTransport.PLAIN; + } + + private LdapDirectoryType parseDirectoryType(String value) { + try { + return LdapDirectoryType.valueOf( + requireText(value, 32) + .toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw invalidConfiguration(); + } + } + + private String defaultSubjectAttribute( + LdapDirectoryType directoryType) { + return switch (directoryType) { + case OPENLDAP -> "entryUUID"; + case ACTIVE_DIRECTORY -> "objectGUID"; + case CUSTOM -> throw invalidConfiguration(); + }; + } + + private String defaultSubjectType( + LdapDirectoryType directoryType) { + return switch (directoryType) { + case OPENLDAP -> "ldap_entry_uuid"; + case ACTIVE_DIRECTORY -> "ad_object_guid"; + case CUSTOM -> throw invalidConfiguration(); + }; + } + + private String requireSearchFilter(String value) { + String filter = requireText(value, MAX_FILTER_LENGTH); + if (count(filter, "{0}") != 1 + || filter.contains("{1}")) { + throw invalidConfiguration(); + } + try { + Filter.create(filter.replace( + "{0}", + Filter.encodeValue("skillhub-probe"))); + } catch (LDAPException exception) { + throw invalidConfiguration(); + } + return filter; + } + + private int count(String value, String needle) { + int count = 0; + int offset = 0; + while ((offset = value.indexOf(needle, offset)) >= 0) { + count++; + offset += needle.length(); + } + return count; + } + + private String requireDn(String value, boolean allowEmpty) { + if (allowEmpty && value != null && value.isEmpty()) { + return value; + } + String dn = requireText(value, MAX_DN_LENGTH); + if (dn.chars().anyMatch(Character::isISOControl)) { + throw invalidConfiguration(); + } + try { + new DN(dn); + } catch (LDAPException exception) { + throw invalidConfiguration(); + } + return dn; + } + + private String requireSecret(String value) { + if (value == null + || value.isBlank() + || value.length() > MAX_SECRET_LENGTH + || value.chars().anyMatch(Character::isISOControl)) { + throw invalidConfiguration(); + } + return value; + } + + private String requireMatching(String value, Pattern pattern) { + if (value == null || !pattern.matcher(value).matches()) { + throw invalidConfiguration(); + } + return value; + } + + private String requireText(String value, int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength + || !value.equals(value.strip())) { + throw invalidConfiguration(); + } + return value; + } + + private Optional optionalAttribute(String value) { + if (value == null || value.isEmpty()) { + return Optional.empty(); + } + if (!value.equals(value.strip()) + || !ATTRIBUTE_PATTERN.matcher(value).matches()) { + throw invalidConfiguration(); + } + return Optional.of(value); + } + + private Optional optionalSubjectType(String value) { + if (value == null || value.isEmpty()) { + return Optional.empty(); + } + if (!SUBJECT_TYPE_PATTERN.matcher(value).matches()) { + throw invalidConfiguration(); + } + return Optional.of(value); + } + + private Duration requireDuration(Duration value) { + if (value == null + || value.isZero() + || value.isNegative() + || value.compareTo(MAX_NETWORK_TIMEOUT) > 0) { + throw invalidConfiguration(); + } + return value; + } + + private IllegalArgumentException invalidConfiguration() { + return new IllegalArgumentException( + "Invalid LDAP provider configuration"); + } + + /** + * Validated provider data. Deliberately not a record so its generated + * {@code toString()} cannot disclose the service-account password. + */ + static final class ResolvedLdapProvider { + + private final String providerCode; + private final String displayName; + private final String authority; + private final URI endpoint; + private final LdapTransport transport; + private final LdapDirectoryType directoryType; + private final String baseDn; + private final String userSearchBase; + private final String userSearchFilter; + private final String bindDn; + private final String bindPassword; + private final String subjectAttribute; + private final String subjectType; + private final Optional usernameAttribute; + private final Optional displayNameAttribute; + private final Optional emailAttribute; + private final Optional avatarUrlAttribute; + private final boolean emailAuthoritative; + private final Duration connectTimeout; + private final Duration readTimeout; + private final Duration poolWaitTimeout; + private final int maximumConcurrentRequests; + private final int maximumAttributeValues; + private final Set requestedAttributes; + + private ResolvedLdapProvider( + String providerCode, + String displayName, + String authority, + URI endpoint, + LdapTransport transport, + LdapDirectoryType directoryType, + String baseDn, + String userSearchBase, + String userSearchFilter, + String bindDn, + String bindPassword, + String subjectAttribute, + String subjectType, + Optional usernameAttribute, + Optional displayNameAttribute, + Optional emailAttribute, + Optional avatarUrlAttribute, + boolean emailAuthoritative, + Duration connectTimeout, + Duration readTimeout, + Duration poolWaitTimeout, + int maximumConcurrentRequests, + int maximumAttributeValues, + Set requestedAttributes) { + this.providerCode = providerCode; + this.displayName = displayName; + this.authority = authority; + this.endpoint = endpoint; + this.transport = transport; + this.directoryType = directoryType; + this.baseDn = baseDn; + this.userSearchBase = userSearchBase; + this.userSearchFilter = userSearchFilter; + this.bindDn = bindDn; + this.bindPassword = bindPassword; + this.subjectAttribute = subjectAttribute; + this.subjectType = subjectType; + this.usernameAttribute = usernameAttribute; + this.displayNameAttribute = displayNameAttribute; + this.emailAttribute = emailAttribute; + this.avatarUrlAttribute = avatarUrlAttribute; + this.emailAuthoritative = emailAuthoritative; + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + this.poolWaitTimeout = poolWaitTimeout; + this.maximumConcurrentRequests = maximumConcurrentRequests; + this.maximumAttributeValues = maximumAttributeValues; + this.requestedAttributes = requestedAttributes; + } + + String providerCode() { + return providerCode; + } + + String displayName() { + return displayName; + } + + String authority() { + return authority; + } + + URI endpoint() { + return endpoint; + } + + LdapTransport transport() { + return transport; + } + + LdapDirectoryType directoryType() { + return directoryType; + } + + String baseDn() { + return baseDn; + } + + String userSearchBase() { + return userSearchBase; + } + + String userSearchFilter() { + return userSearchFilter; + } + + String bindDn() { + return bindDn; + } + + String bindPassword() { + return bindPassword; + } + + String subjectAttribute() { + return subjectAttribute; + } + + String subjectType() { + return subjectType; + } + + Optional usernameAttribute() { + return usernameAttribute; + } + + Optional displayNameAttribute() { + return displayNameAttribute; + } + + Optional emailAttribute() { + return emailAttribute; + } + + Optional avatarUrlAttribute() { + return avatarUrlAttribute; + } + + boolean emailAuthoritative() { + return emailAuthoritative; + } + + Duration connectTimeout() { + return connectTimeout; + } + + Duration readTimeout() { + return readTimeout; + } + + Duration poolWaitTimeout() { + return poolWaitTimeout; + } + + int maximumConcurrentRequests() { + return maximumConcurrentRequests; + } + + int maximumAttributeValues() { + return maximumAttributeValues; + } + + Set requestedAttributes() { + return requestedAttributes; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTransport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTransport.java new file mode 100644 index 000000000..c9adfcb33 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapTransport.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.auth.ldap; + +enum LdapTransport { + PLAIN, + STARTTLS, + LDAPS +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClient.java new file mode 100644 index 000000000..b7fd2591e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClient.java @@ -0,0 +1,518 @@ +package com.iflytek.skillhub.auth.ldap; + +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import com.unboundid.ldap.sdk.DN; +import com.unboundid.ldap.sdk.DereferencePolicy; +import com.unboundid.ldap.sdk.Filter; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPConnectionOptions; +import com.unboundid.ldap.sdk.LDAPConnectionPool; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.PostConnectProcessor; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchRequest; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; +import com.unboundid.ldap.sdk.SearchScope; +import com.unboundid.ldap.sdk.SimpleBindRequest; +import com.unboundid.ldap.sdk.SingleServerSet; +import com.unboundid.ldap.sdk.StartTLSPostConnectProcessor; +import com.unboundid.util.ssl.HostNameSSLSocketVerifier; +import com.unboundid.util.ssl.JVMDefaultTrustManager; +import com.unboundid.util.ssl.SSLUtil; +import jakarta.annotation.PreDestroy; +import java.security.GeneralSecurityException; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocketFactory; +import javax.net.SocketFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * UnboundID-backed LDAP protocol client with a lazily initialized service + * connection pool. + */ +@Component +final class UnboundIdLdapDirectoryClient + implements LdapDirectoryClient, AutoCloseable { + + private static final int SEARCH_RESULT_LIMIT = 2; + private static final int MAX_MESSAGE_SIZE_BYTES = 1024 * 1024; + private static final int MAX_ATTRIBUTE_VALUE_LENGTH = 8192; + + private final Clock clock; + private final TlsSocketFactorySource tlsSocketFactorySource; + private volatile PoolHolder poolHolder; + + @Autowired + UnboundIdLdapDirectoryClient() { + this( + Clock.systemUTC(), + UnboundIdLdapDirectoryClient + ::jvmDefaultTlsSocketFactory); + } + + UnboundIdLdapDirectoryClient(Clock clock) { + this( + clock, + UnboundIdLdapDirectoryClient + ::jvmDefaultTlsSocketFactory); + } + + UnboundIdLdapDirectoryClient( + Clock clock, + TlsSocketFactorySource tlsSocketFactorySource) { + this.clock = clock; + this.tlsSocketFactorySource = tlsSocketFactorySource; + } + + @Override + public LdapAuthenticatedEntry authenticate( + LdapProviderConfiguration.ResolvedLdapProvider provider, + CredentialAuthenticationRequest request) { + requireCredentials(request); + PoolHolder holder = requirePool(provider); + SearchResultEntry entry = findUniqueEntry( + holder.pool(), + provider, + request.username()); + verifyUserPassword( + holder, + entry.getDN(), + request.password()); + return authenticatedEntry(provider, entry); + } + + private SearchResultEntry findUniqueEntry( + LDAPConnectionPool pool, + LdapProviderConfiguration.ResolvedLdapProvider provider, + String username) { + SearchRequest request; + try { + String searchBase = searchBase(provider); + String escapedUsername = Filter.encodeValue(username); + Filter filter = Filter.create( + provider.userSearchFilter().replace( + "{0}", + escapedUsername)); + request = new SearchRequest( + searchBase, + SearchScope.SUB, + DereferencePolicy.NEVER, + SEARCH_RESULT_LIMIT, + timeLimitSeconds(provider.readTimeout()), + false, + filter, + provider.requestedAttributes() + .toArray(String[]::new)); + } catch (LDAPException exception) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_MISCONFIGURED, + exception); + } + + SearchResult result; + try { + result = pool.search(request); + } catch (LDAPException exception) { + throw classify(exception, FailurePhase.SEARCH); + } + if (result.getEntryCount() == 0) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND); + } + if (result.getEntryCount() != 1) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE); + } + return result.getSearchEntries().getFirst(); + } + + private void verifyUserPassword( + PoolHolder holder, + String userDn, + String password) { + try (LDAPConnection connection = + holder.serverSet().getConnection()) { + if (holder.postConnectProcessor() != null) { + holder.postConnectProcessor() + .processPreAuthenticatedConnection(connection); + } + connection.bind(new SimpleBindRequest(userDn, password)); + } catch (LDAPException exception) { + throw classify(exception, FailurePhase.USER_BIND); + } + } + + private LdapAuthenticatedEntry authenticatedEntry( + LdapProviderConfiguration.ResolvedLdapProvider provider, + SearchResultEntry entry) { + String subject = subject(provider, entry); + Map> attributes = new LinkedHashMap<>(); + for (String attributeName : provider.requestedAttributes()) { + String[] values = entry.getAttributeValues(attributeName); + if (values == null) { + continue; + } + if (values.length > provider.maximumAttributeValues()) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE); + } + List copied = Arrays.stream(values) + .filter(value -> value != null + && value.length() + <= MAX_ATTRIBUTE_VALUE_LENGTH) + .toList(); + if (copied.size() != values.length) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE); + } + attributes.put(attributeName, copied); + } + return new LdapAuthenticatedEntry( + subject, + attributes, + clock.instant()); + } + + private String subject( + LdapProviderConfiguration.ResolvedLdapProvider provider, + SearchResultEntry entry) { + return switch (provider.directoryType()) { + case OPENLDAP -> openLdapSubject(provider, entry); + case ACTIVE_DIRECTORY -> activeDirectorySubject( + provider, + entry); + case CUSTOM -> customSubject(provider, entry); + }; + } + + private String openLdapSubject( + LdapProviderConfiguration.ResolvedLdapProvider provider, + SearchResultEntry entry) { + String value = uniqueTextSubject(provider, entry); + try { + return UUID.fromString(value).toString(); + } catch (IllegalArgumentException exception) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE, + exception); + } + } + + private String activeDirectorySubject( + LdapProviderConfiguration.ResolvedLdapProvider provider, + SearchResultEntry entry) { + byte[][] values = entry.getAttributeValueByteArrays( + provider.subjectAttribute()); + if (values == null || values.length != 1 + || values[0].length != 16) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE); + } + byte[] value = values[0]; + return "%02x%02x%02x%02x-%02x%02x-%02x%02x-" + .formatted( + unsigned(value[3]), + unsigned(value[2]), + unsigned(value[1]), + unsigned(value[0]), + unsigned(value[5]), + unsigned(value[4]), + unsigned(value[7]), + unsigned(value[6])) + + "%02x%02x-%02x%02x%02x%02x%02x%02x" + .formatted( + unsigned(value[8]), + unsigned(value[9]), + unsigned(value[10]), + unsigned(value[11]), + unsigned(value[12]), + unsigned(value[13]), + unsigned(value[14]), + unsigned(value[15])); + } + + private String customSubject( + LdapProviderConfiguration.ResolvedLdapProvider provider, + SearchResultEntry entry) { + return uniqueTextSubject(provider, entry); + } + + private String uniqueTextSubject( + LdapProviderConfiguration.ResolvedLdapProvider provider, + SearchResultEntry entry) { + String[] values = entry.getAttributeValues( + provider.subjectAttribute()); + if (values == null || values.length != 1 + || values[0] == null + || values[0].isBlank() + || values[0].length() > 4096 + || values[0].chars().anyMatch(Character::isISOControl)) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE); + } + return values[0]; + } + + private int unsigned(byte value) { + return Byte.toUnsignedInt(value); + } + + private String searchBase( + LdapProviderConfiguration.ResolvedLdapProvider provider) + throws LDAPException { + String value = provider.userSearchBase().isEmpty() + ? provider.baseDn() + : provider.userSearchBase() + "," + provider.baseDn(); + return new DN(value).toString(); + } + + private int timeLimitSeconds(Duration timeout) { + long milliseconds = timeout.toMillis(); + return Math.toIntExact(Math.max( + 1, + Math.ceilDiv(milliseconds, 1000))); + } + + private PoolHolder requirePool( + LdapProviderConfiguration.ResolvedLdapProvider provider) { + PoolHolder current = poolHolder; + if (current != null) { + return current; + } + synchronized (this) { + if (poolHolder == null) { + poolHolder = createPool(provider); + } + return poolHolder; + } + } + + private PoolHolder createPool( + LdapProviderConfiguration.ResolvedLdapProvider provider) { + try { + LDAPConnectionOptions options = connectionOptions(provider); + SSLSocketFactory tlsSocketFactory = null; + SocketFactory socketFactory = SocketFactory.getDefault(); + PostConnectProcessor postConnectProcessor = null; + if (provider.transport() != LdapTransport.PLAIN) { + tlsSocketFactory = tlsSocketFactorySource.create(); + } + if (provider.transport() == LdapTransport.LDAPS) { + socketFactory = tlsSocketFactory; + } else if (provider.transport() + == LdapTransport.STARTTLS) { + postConnectProcessor = + new StartTLSPostConnectProcessor( + tlsSocketFactory); + } + + SingleServerSet serverSet = new SingleServerSet( + provider.endpoint().getHost(), + endpointPort(provider), + socketFactory, + options); + LDAPConnectionPool pool = new LDAPConnectionPool( + serverSet, + new SimpleBindRequest( + provider.bindDn(), + provider.bindPassword()), + 0, + provider.maximumConcurrentRequests(), + postConnectProcessor); + pool.setCreateIfNecessary(true); + pool.setMaxWaitTimeMillis( + provider.poolWaitTimeout().toMillis()); + pool.setRetryFailedOperationsDueToInvalidConnections(false); + pool.setConnectionPoolName( + "skillhub-ldap-" + provider.providerCode()); + return new PoolHolder( + serverSet, + pool, + postConnectProcessor); + } catch (LDAPException exception) { + throw classify(exception, FailurePhase.SERVICE_BIND); + } catch (GeneralSecurityException exception) { + throw failure( + ProviderAuthenticationFailureCode + .TLS_VALIDATION_FAILED, + exception); + } + } + + private LDAPConnectionOptions connectionOptions( + LdapProviderConfiguration.ResolvedLdapProvider provider) { + LDAPConnectionOptions options = new LDAPConnectionOptions(); + options.setConnectTimeoutMillis( + Math.toIntExact(provider.connectTimeout().toMillis())); + options.setResponseTimeoutMillis( + provider.readTimeout().toMillis()); + options.setAbandonOnTimeout(true); + options.setFollowReferrals(false); + options.setUseSchema(false); + options.setMaxMessageSize(MAX_MESSAGE_SIZE_BYTES); + options.setSSLSocketVerifier( + new HostNameSSLSocketVerifier(true)); + return options; + } + + private static SSLSocketFactory jvmDefaultTlsSocketFactory() + throws GeneralSecurityException { + return new SSLUtil(JVMDefaultTrustManager.getInstance()) + .createSSLSocketFactory(); + } + + private int endpointPort( + LdapProviderConfiguration.ResolvedLdapProvider provider) { + if (provider.endpoint().getPort() > 0) { + return provider.endpoint().getPort(); + } + return provider.transport() == LdapTransport.LDAPS + ? 636 + : 389; + } + + private ProviderAuthenticationException classify( + LDAPException exception, + FailurePhase phase) { + if (hasTlsCause(exception)) { + return failure( + ProviderAuthenticationFailureCode + .TLS_VALIDATION_FAILED, + exception); + } + ResultCode code = exception.getResultCode(); + if (code == ResultCode.INVALID_CREDENTIALS) { + return failure( + phase == FailurePhase.USER_BIND + ? ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS + : ProviderAuthenticationFailureCode + .UPSTREAM_MISCONFIGURED, + exception); + } + if (code == ResultCode.INSUFFICIENT_ACCESS_RIGHTS + || code == ResultCode.AUTH_METHOD_NOT_SUPPORTED + || code == ResultCode.STRONG_AUTH_REQUIRED + || code == ResultCode.CONFIDENTIALITY_REQUIRED + || code == ResultCode.INVALID_DN_SYNTAX + || code == ResultCode.FILTER_ERROR + || code == ResultCode.PARAM_ERROR + || code == ResultCode.NO_SUCH_OBJECT) { + return failure( + ProviderAuthenticationFailureCode + .UPSTREAM_MISCONFIGURED, + exception); + } + if (code == ResultCode.SIZE_LIMIT_EXCEEDED + || code == ResultCode.ADMIN_LIMIT_EXCEEDED + || code == ResultCode.DECODING_ERROR + || code == ResultCode.PROTOCOL_ERROR) { + return failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE, + exception); + } + return failure( + ProviderAuthenticationFailureCode + .UPSTREAM_UNAVAILABLE, + exception); + } + + private boolean hasTlsCause(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof SSLException + || current instanceof java.security.cert + .CertificateException) { + return true; + } + if (current instanceof LDAPException ldapException) { + String detail = ldapException.getExceptionMessage() + .toLowerCase(Locale.ROOT); + if (detail.contains("hostname verification failed") + || detail.contains("tls negotiation") + || detail.contains("ssl handshake") + || detail.contains("certificate path")) { + return true; + } + } + if (current.getCause() == current) { + break; + } + current = current.getCause(); + } + return false; + } + + private void requireCredentials( + CredentialAuthenticationRequest request) { + if (request == null + || request.username() == null + || request.username().isBlank() + || request.password() == null + || request.password().isBlank()) { + throw failure( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS); + } + } + + private ProviderAuthenticationException failure( + ProviderAuthenticationFailureCode code) { + return new ProviderAuthenticationException(code); + } + + private ProviderAuthenticationException failure( + ProviderAuthenticationFailureCode code, + Throwable cause) { + return new ProviderAuthenticationException(code, cause); + } + + @Override + @PreDestroy + public synchronized void close() { + if (poolHolder != null) { + poolHolder.pool().close(); + poolHolder = null; + } + } + + private enum FailurePhase { + SERVICE_BIND, + SEARCH, + USER_BIND + } + + private record PoolHolder( + SingleServerSet serverSet, + LDAPConnectionPool pool, + PostConnectProcessor postConnectProcessor + ) { + } + + @FunctionalInterface + interface TlsSocketFactorySource { + + SSLSocketFactory create() throws GeneralSecurityException; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/package-info.java new file mode 100644 index 000000000..28a0e5748 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/package-info.java @@ -0,0 +1,4 @@ +/** + * LDAP and Active Directory credential-provider integration. + */ +package com.iflytek.skillhub.auth.ldap; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java index a1dce2afe..c7f745977 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderAuthenticationFailureCode.java @@ -6,6 +6,7 @@ */ public enum ProviderAuthenticationFailureCode { UPSTREAM_INVALID_CREDENTIALS, + UPSTREAM_IDENTITY_NOT_FOUND, UPSTREAM_ACCESS_DENIED, UPSTREAM_UNAVAILABLE, UPSTREAM_MISCONFIGURED, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java index 081d5c5aa..5a2388d6b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/ProviderInstanceDefinition.java @@ -24,6 +24,7 @@ public record ProviderInstanceDefinition( List emailAttributes, List avatarAttributes, EmailAssurance emailAssuranceLimit, + boolean authoritativeEmailSource, boolean enabled ) { private static final Pattern PROVIDER_CODE_PATTERN = @@ -51,6 +52,11 @@ public record ProviderInstanceDefinition( Objects.requireNonNull(emailAttributes, "emailAttributes"); Objects.requireNonNull(avatarAttributes, "avatarAttributes"); Objects.requireNonNull(emailAssuranceLimit, "emailAssuranceLimit"); + if (authoritativeEmailSource + && emailAssuranceLimit != EmailAssurance.AUTHORITATIVE) { + throw new IllegalArgumentException( + "Authoritative email source requires authoritative assurance limit"); + } if (!PROVIDER_CODE_PATTERN.matcher(providerCode).matches()) { throw new IllegalArgumentException("Invalid provider code"); @@ -101,6 +107,35 @@ public record ProviderInstanceDefinition( "avatarAttributes"); } + public ProviderInstanceDefinition( + String providerCode, + String protocol, + String canonicalAuthority, + String displayName, + String primarySubjectType, + String legacyPrimarySubjectType, + Map subjectNormalizations, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes, + EmailAssurance emailAssuranceLimit, + boolean enabled) { + this( + providerCode, + protocol, + canonicalAuthority, + displayName, + primarySubjectType, + legacyPrimarySubjectType, + subjectNormalizations, + displayNameAttributes, + emailAttributes, + avatarAttributes, + emailAssuranceLimit, + false, + enabled); + } + public ProviderInstanceDefinition( String providerCode, String protocol, @@ -125,6 +160,7 @@ public ProviderInstanceDefinition( emailAttributes, avatarAttributes, emailAssuranceLimit, + false, true); } 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 8280baef5..326784052 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 @@ -84,6 +84,47 @@ void clampsEmailTrustToDescriptorAssuranceLimit() { assertThat(assertion.primarySubject().value()).isEqualTo("CaseSensitiveSubject"); } + @Test + void promotesAssertedEmailOnlyForTrustedAuthoritativeSource() { + ProviderDescriptor descriptor = new ProviderDescriptor( + "corporate-ldap", + "ldap", + "corp-directory-v1", + "Corporate Directory", + "ldap_entry_uuid", + "ldap_entry_uuid", + Map.of( + "ldap_entry_uuid", + SubjectCanonicalizer.EXACT), + List.of("ldap_display_name"), + List.of("ldap_email"), + List.of(), + EmailAssurance.AUTHORITATIVE, + true, + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()); + ProviderAuthenticationResult result = result( + new SubjectCandidate( + "ldap_entry_uuid", + "550e8400-e29b-41d4-a716-446655440000"), + List.of(), + Map.of( + "ldap_email", + values( + "alice@example.com", + ProviderAttributeTrust.ASSERTED)), + "ldap"); + + IdentityAssertion assertion = factory.create( + descriptor, + result); + + assertThat(assertion.profile().email()).contains( + new EmailClaim( + "alice@example.com", + EmailAssurance.AUTHORITATIVE)); + } + @Test void rejectsProtocolClaimThatDoesNotMatchTrustedDescriptor() { ProviderAuthenticationResult result = result( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapterTest.java new file mode 100644 index 000000000..034b2dc3c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapAuthenticationAdapterTest.java @@ -0,0 +1,161 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import com.iflytek.skillhub.auth.provider.ProviderConformanceKit; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class LdapAuthenticationAdapterTest { + + @Test + void mapsAuthenticatedEntryIntoUnifiedProviderFacts() + throws IOException { + LdapProperties properties = validProperties(); + LdapProviderConfiguration configuration = + new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + "prod")); + LdapDirectoryClient directoryClient = (provider, request) -> { + assertThat(request.username()).isEqualTo("alice"); + assertThat(request.password()).isEqualTo("fixture-password"); + return new LdapAuthenticatedEntry( + "550e8400-e29b-41d4-a716-446655440000", + Map.of( + "uid", List.of("alice"), + "displayName", List.of("Alice Directory"), + "mail", List.of("alice@example.com"), + "unmapped", List.of("must-not-leak")), + Instant.parse("2026-07-31T10:00:00Z")); + }; + LdapAuthenticationAdapter adapter = + new LdapAuthenticationAdapter( + configuration, + directoryClient); + + var result = ProviderConformanceKit.verifyCredential( + adapter, + new CredentialAuthenticationRequest( + "alice", + "fixture-password")); + + assertThat(result.primarySubject().type()) + .isEqualTo("ldap_entry_uuid"); + assertThat(result.primarySubject().value()) + .isEqualTo("550e8400-e29b-41d4-a716-446655440000"); + assertThat(result.attributes()).containsOnlyKeys( + LdapAuthenticationAdapter.USERNAME_ATTRIBUTE, + LdapAuthenticationAdapter.DISPLAY_NAME_ATTRIBUTE, + LdapAuthenticationAdapter.EMAIL_ATTRIBUTE); + assertThat(result.attributes() + .get(LdapAuthenticationAdapter.EMAIL_ATTRIBUTE) + .getFirst() + .trust()).isEqualTo(ProviderAttributeTrust.ASSERTED); + assertThat(adapter.provider().emailAssuranceLimit()) + .isEqualTo(EmailAssurance.PROVIDER_ASSERTED); + assertThat(adapter.provider().authoritativeEmailSource()) + .isFalse(); + ProviderConformanceKit.verifyAdapterBoundary( + LdapAuthenticationAdapter.class); + } + + @Test + void keepsAuthoritativeDirectoryEmailAsAssertedProviderFact() + throws IOException { + LdapProperties properties = validProperties(); + properties.setEmailAuthoritative(true); + LdapAuthenticationAdapter adapter = + new LdapAuthenticationAdapter( + new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + "prod")), + (provider, request) -> + new LdapAuthenticatedEntry( + "550e8400-e29b-41d4-a716-446655440000", + Map.of( + "mail", + List.of( + "alice@example.com")), + Instant.parse( + "2026-07-31T10:00:00Z"))); + + var result = ProviderConformanceKit.verifyCredential( + adapter, + new CredentialAuthenticationRequest( + "alice", + "fixture-password")); + + assertThat(result.attributes() + .get(LdapAuthenticationAdapter.EMAIL_ATTRIBUTE) + .getFirst() + .trust()).isEqualTo(ProviderAttributeTrust.ASSERTED); + assertThat(adapter.provider().emailAssuranceLimit()) + .isEqualTo(EmailAssurance.AUTHORITATIVE); + assertThat(adapter.provider().authoritativeEmailSource()) + .isTrue(); + } + + @Test + void recordsOnlyBoundedProtocolOutcomeLabels() { + LdapProperties properties = validProperties(); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + LdapAuthenticationAdapter adapter = + new LdapAuthenticationAdapter( + new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + "prod")), + (provider, request) -> { + throw new ProviderAuthenticationException( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS); + }, + new LdapAuthenticationMetrics(registry)); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + adapter.authenticate( + new CredentialAuthenticationRequest( + "alice", + "fixture-password"))) + .isInstanceOf(ProviderAuthenticationException.class); + + assertThat(registry.get("skillhub.auth.ldap") + .tags( + "provider", + "corporate-ldap", + "transport", + "ldaps", + "result", + "upstream_invalid_credentials") + .counter() + .count()).isEqualTo(1.0); + } + + private static LdapProperties validProperties() { + LdapProperties properties = new LdapProperties(); + properties.setEnabled(true); + properties.setProviderCode("corporate-ldap"); + properties.setDisplayName("Corporate Directory"); + properties.setAuthority("corp-directory-v1"); + properties.setUrl("ldaps://ldap.example.com:636"); + properties.setBaseDn("dc=example,dc=com"); + properties.setBindDn("cn=reader,dc=example,dc=com"); + properties.setBindPassword("fixture-password"); + return properties; + } +} 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 new file mode 100644 index 000000000..c9a159295 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/LdapProviderConfigurationTest.java @@ -0,0 +1,111 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class LdapProviderConfigurationTest { + + @Test + void resolvesOpenLdapProviderWithStableEntryUuidSubject() { + LdapProperties properties = validProperties(); + + LdapProviderConfiguration.ResolvedLdapProvider resolved = + new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + "prod")) + .requireResolved(); + + assertThat(resolved.providerCode()) + .isEqualTo("corporate-ldap"); + assertThat(resolved.authority()) + .isEqualTo("corp-directory-v1"); + assertThat(resolved.subjectAttribute()) + .isEqualTo("entryUUID"); + assertThat(resolved.subjectType()) + .isEqualTo("ldap_entry_uuid"); + assertThat(resolved.transport()) + .isEqualTo(LdapTransport.LDAPS); + } + + @Test + void resolvesActiveDirectoryObjectGuidSubjectType() { + LdapProperties properties = validProperties(); + properties.setDirectoryType("ACTIVE_DIRECTORY"); + + LdapProviderConfiguration.ResolvedLdapProvider resolved = + configuration(properties, "prod").requireResolved(); + + assertThat(resolved.subjectAttribute()).isEqualTo("objectGUID"); + assertThat(resolved.subjectType()).isEqualTo("ad_object_guid"); + } + + @Test + void allowsPlainLdapOnlyWithExplicitNonProductionEscapeHatch() { + LdapProperties properties = validProperties(); + properties.setUrl("ldap://ldap.example.com:389"); + properties.setAllowInsecureForTesting(true); + + assertThat(configuration(properties, "test") + .requireResolved().transport()) + .isEqualTo(LdapTransport.PLAIN); + assertThatThrownBy(() -> configuration(properties, "prod") + .requireResolved()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid LDAP provider configuration"); + } + + @Test + void rejectsMalformedDnAndSearchFilterBeforeAnyNetworkCall() { + LdapProperties invalidDn = validProperties(); + invalidDn.setBaseDn("not-a-dn"); + assertThatThrownBy(() -> configuration(invalidDn, "prod") + .requireResolved()) + .isInstanceOf(IllegalArgumentException.class); + + LdapProperties invalidFilter = validProperties(); + invalidFilter.setUserSearchFilter("(&(uid={0})"); + assertThatThrownBy(() -> configuration(invalidFilter, "prod") + .requireResolved()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void resolvedProviderStringDoesNotExposeBindPassword() { + LdapProperties properties = validProperties(); + + Object resolved = configuration(properties, "prod") + .requireResolved(); + + assertThat(resolved.toString()) + .doesNotContain("fixture-password") + .doesNotContain("bindPassword"); + } + + private static LdapProviderConfiguration configuration( + LdapProperties properties, + String profile) { + return new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + profile)); + } + + private static LdapProperties validProperties() { + LdapProperties properties = new LdapProperties(); + properties.setEnabled(true); + properties.setProviderCode("corporate-ldap"); + properties.setDisplayName("Corporate Directory"); + properties.setAuthority("corp-directory-v1"); + properties.setUrl("ldaps://ldap.example.com:636"); + properties.setBaseDn("dc=example,dc=com"); + properties.setBindDn("cn=reader,dc=example,dc=com"); + properties.setBindPassword("fixture-password"); + return properties; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClientTest.java new file mode 100644 index 000000000..233bc81d5 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/ldap/UnboundIdLdapDirectoryClientTest.java @@ -0,0 +1,511 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode; +import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; +import com.unboundid.ldap.listener.InMemoryListenerConfig; +import com.unboundid.ldap.listener.SelfSignedCertificateGenerator; +import com.unboundid.ldap.sdk.Modification; +import com.unboundid.ldap.sdk.ModificationType; +import com.unboundid.util.ObjectPair; +import com.unboundid.util.ssl.KeyStoreKeyManager; +import com.unboundid.util.ssl.SSLUtil; +import com.unboundid.util.ssl.TrustStoreTrustManager; +import java.io.File; +import java.net.InetAddress; +import java.nio.file.Files; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSocketFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class UnboundIdLdapDirectoryClientTest { + + private static final Instant AUTHENTICATED_AT = + Instant.parse("2026-07-31T10:00:00Z"); + + private InMemoryDirectoryServer directory; + private UnboundIdLdapDirectoryClient client; + private File tlsKeyStore; + + @BeforeEach + void setUp() throws Exception { + startDirectory(null); + client = new UnboundIdLdapDirectoryClient( + Clock.fixed(AUTHENTICATED_AT, ZoneOffset.UTC)); + } + + private void startDirectory(InMemoryListenerConfig listener) + throws Exception { + InMemoryDirectoryServerConfig serverConfiguration = + new InMemoryDirectoryServerConfig( + "dc=example,dc=com"); + if (listener != null) { + serverConfiguration.setListenerConfigs(listener); + } + serverConfiguration.setSchema(null); + serverConfiguration.setGenerateOperationalAttributes(true); + serverConfiguration.addAdditionalBindCredentials( + "cn=reader,dc=example,dc=com", + "reader-password"); + directory = new InMemoryDirectoryServer(serverConfiguration); + directory.startListening(); + directory.add( + "dn: dc=example,dc=com", + "objectClass: top", + "objectClass: domain", + "dc: example"); + directory.add( + "dn: ou=people,dc=example,dc=com", + "objectClass: top", + "objectClass: organizationalUnit", + "ou: people"); + directory.add( + "dn: uid=alice,ou=people,dc=example,dc=com", + "objectClass: top", + "objectClass: person", + "objectClass: inetOrgPerson", + "uid: alice", + "cn: Alice Directory", + "sn: Directory", + "displayName: Alice Directory", + "mail: alice@example.com", + "userPassword: alice-password"); + } + + @AfterEach + void tearDown() { + if (client != null) { + client.close(); + } + if (directory != null) { + directory.shutDown(true); + } + if (tlsKeyStore != null) { + try { + Files.deleteIfExists(tlsKeyStore.toPath()); + } catch (Exception ignored) { + // Test-only best-effort cleanup. + } + } + } + + @Test + void authenticatesUniqueOpenLdapEntryAndReturnsStableFacts() { + LdapAuthenticatedEntry entry = client.authenticate( + provider(), + new CredentialAuthenticationRequest( + "alice", + "alice-password")); + + assertThat(entry.subject()) + .matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-" + + "[0-9a-f]{4}-[0-9a-f]{12}"); + assertThat(entry.attributes()) + .containsEntry("uid", java.util.List.of("alice")) + .containsEntry( + "displayName", + java.util.List.of("Alice Directory")) + .containsEntry( + "mail", + java.util.List.of("alice@example.com")); + assertThat(entry.authenticatedAt()).isEqualTo(AUTHENTICATED_AT); + } + + @Test + void distinguishesUnknownIdentityFromInvalidPasswordWithoutDetails() { + assertFailure( + "unknown", + "irrelevant-password", + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND); + assertFailure( + "alice", + "wrong-password", + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_CREDENTIALS); + } + + @Test + void escapesUsernameBeforeSubstitutingSearchFilter() { + assertFailure( + "*)(uid=*)", + "alice-password", + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND); + } + + @Test + void rejectsAmbiguousSearchResult() throws Exception { + directory.add( + "dn: uid=bob,ou=people,dc=example,dc=com", + "objectClass: top", + "objectClass: person", + "objectClass: inetOrgPerson", + "uid: bob", + "cn: Bob Directory", + "sn: Directory", + "userPassword: bob-password"); + + assertThatThrownBy(() -> client.authenticate( + provider( + "OPENLDAP", + "(|(uid={0})(objectClass=inetOrgPerson))", + null, + null), + new CredentialAuthenticationRequest( + "alice", + "alice-password"))) + .isInstanceOfSatisfying( + ProviderAuthenticationException.class, + failure -> assertThat(failure.getReasonCode()) + .isEqualTo( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE)); + } + + @Test + void rejectsEntryWithoutConfiguredStableSubject() { + assertThatThrownBy(() -> client.authenticate( + provider( + "CUSTOM", + "(uid={0})", + "immutableId", + "custom_immutable_id"), + new CredentialAuthenticationRequest( + "alice", + "alice-password"))) + .isInstanceOfSatisfying( + ProviderAuthenticationException.class, + failure -> assertThat(failure.getReasonCode()) + .isEqualTo( + ProviderAuthenticationFailureCode + .UPSTREAM_INVALID_RESPONSE)); + } + + @Test + void convertsActiveDirectoryObjectGuidUsingFixedByteOrder() + throws Exception { + directory.modify( + "uid=alice,ou=people,dc=example,dc=com", + new Modification( + ModificationType.ADD, + "objectGUID", + new byte[]{ + 0x33, 0x22, 0x11, 0x00, + 0x55, 0x44, + 0x77, 0x66, + (byte) 0x88, (byte) 0x99, + (byte) 0xaa, (byte) 0xbb, + (byte) 0xcc, (byte) 0xdd, + (byte) 0xee, (byte) 0xff + })); + + LdapAuthenticatedEntry entry = client.authenticate( + provider( + "ACTIVE_DIRECTORY", + "(uid={0})", + null, + null), + new CredentialAuthenticationRequest( + "alice", + "alice-password")); + + assertThat(entry.subject()) + .isEqualTo("00112233-4455-6677-8899-aabbccddeeff"); + } + + @Test + void classifiesDirectoryTimeoutAsUnavailable() { + directory.setProcessingDelayMillis(250); + + assertThatThrownBy(() -> client.authenticate( + provider( + "OPENLDAP", + "(uid={0})", + null, + null, + Duration.ofMillis(50)), + new CredentialAuthenticationRequest( + "alice", + "alice-password"))) + .isInstanceOfSatisfying( + ProviderAuthenticationException.class, + failure -> assertThat(failure.getReasonCode()) + .isEqualTo( + ProviderAuthenticationFailureCode + .UPSTREAM_UNAVAILABLE)); + } + + @Test + void classifiesUnavailableDirectoryWithoutLeakingEndpoint() { + int closedPort = directory.getListenPort(); + directory.shutDown(true); + + assertThatThrownBy(() -> client.authenticate( + provider( + "OPENLDAP", + "(uid={0})", + null, + null, + Duration.ofMillis(100), + closedPort), + new CredentialAuthenticationRequest( + "alice", + "alice-password"))) + .isInstanceOfSatisfying( + ProviderAuthenticationException.class, + failure -> { + assertThat(failure.getReasonCode()) + .isEqualTo( + ProviderAuthenticationFailureCode + .UPSTREAM_UNAVAILABLE); + assertThat(failure.getMessage()) + .isEqualTo("UPSTREAM_UNAVAILABLE"); + }); + } + + @Test + void supportsLdapsWithTrustedCertificate() throws Exception { + TlsMaterial tls = tlsMaterial(); + InetAddress tlsAddress = InetAddress.getLocalHost(); + restartDirectory(InMemoryListenerConfig.createLDAPSConfig( + "ldaps", + tlsAddress, + 0, + tls.serverSocketFactory(), + tls.clientSocketFactory())); + client = trustedTlsClient(tls); + + LdapAuthenticatedEntry entry = client.authenticate( + provider( + "ldaps://" + endpointHost(tlsAddress) + ":" + + directory.getListenPort(), + false), + new CredentialAuthenticationRequest( + "alice", + "alice-password")); + + assertThat(entry.subject()) + .matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}"); + } + + @Test + void supportsStartTlsWithTrustedCertificate() throws Exception { + TlsMaterial tls = tlsMaterial(); + InetAddress tlsAddress = InetAddress.getLocalHost(); + restartDirectory(InMemoryListenerConfig.createLDAPConfig( + "starttls", + tlsAddress, + 0, + tls.serverTlsSocketFactory())); + client = trustedTlsClient(tls); + + LdapAuthenticatedEntry entry = client.authenticate( + provider( + "ldap://" + endpointHost(tlsAddress) + ":" + + directory.getListenPort(), + true), + new CredentialAuthenticationRequest( + "alice", + "alice-password")); + + assertThat(entry.subject()).isNotBlank(); + } + + @Test + void rejectsUntrustedLdapsCertificate() throws Exception { + TlsMaterial tls = tlsMaterial(); + InetAddress tlsAddress = InetAddress.getLocalHost(); + restartDirectory(InMemoryListenerConfig.createLDAPSConfig( + "ldaps", + tlsAddress, + 0, + tls.serverSocketFactory(), + tls.clientSocketFactory())); + + assertThatThrownBy(() -> client.authenticate( + provider( + "ldaps://" + endpointHost(tlsAddress) + ":" + + directory.getListenPort(), + false), + new CredentialAuthenticationRequest( + "alice", + "alice-password"))) + .isInstanceOfSatisfying( + ProviderAuthenticationException.class, + failure -> assertThat(failure.getReasonCode()) + .isEqualTo( + ProviderAuthenticationFailureCode + .TLS_VALIDATION_FAILED)); + } + + private void assertFailure( + String username, + String password, + ProviderAuthenticationFailureCode expected) { + assertThatThrownBy(() -> client.authenticate( + provider(), + new CredentialAuthenticationRequest( + username, + password))) + .isInstanceOfSatisfying( + ProviderAuthenticationException.class, + failure -> { + assertThat(failure.getReasonCode()) + .isEqualTo(expected); + assertThat(failure.getMessage()) + .isEqualTo(expected.name()); + }); + } + + private LdapProviderConfiguration.ResolvedLdapProvider provider() { + return provider("OPENLDAP", "(uid={0})", null, null); + } + + private LdapProviderConfiguration.ResolvedLdapProvider provider( + String directoryType, + String searchFilter, + String subjectAttribute, + String subjectType) { + return provider( + directoryType, + searchFilter, + subjectAttribute, + subjectType, + Duration.ofSeconds(10)); + } + + private LdapProviderConfiguration.ResolvedLdapProvider provider( + String directoryType, + String searchFilter, + String subjectAttribute, + String subjectType, + Duration readTimeout) { + return provider( + directoryType, + searchFilter, + subjectAttribute, + subjectType, + readTimeout, + directory.getListenPort()); + } + + private LdapProviderConfiguration.ResolvedLdapProvider provider( + String directoryType, + String searchFilter, + String subjectAttribute, + String subjectType, + Duration readTimeout, + int port) { + LdapProperties properties = new LdapProperties(); + properties.setEnabled(true); + properties.setProviderCode("corporate-ldap"); + properties.setDisplayName("Corporate Directory"); + properties.setAuthority("corp-directory-v1"); + properties.setUrl( + "ldap://127.0.0.1:" + port); + properties.setAllowInsecureForTesting(true); + properties.setBaseDn("dc=example,dc=com"); + properties.setUserSearchBase("ou=people"); + properties.setBindDn("cn=reader,dc=example,dc=com"); + properties.setBindPassword("reader-password"); + properties.setDirectoryType(directoryType); + properties.setUserSearchFilter(searchFilter); + properties.setSubjectAttribute(subjectAttribute); + properties.setSubjectType(subjectType); + properties.setConnectTimeout(readTimeout); + properties.setReadTimeout(readTimeout); + return new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + "test")) + .requireResolved(); + } + + private LdapProviderConfiguration.ResolvedLdapProvider provider( + String endpoint, + boolean startTls) { + LdapProperties properties = new LdapProperties(); + properties.setEnabled(true); + properties.setProviderCode("corporate-ldap"); + properties.setDisplayName("Corporate Directory"); + properties.setAuthority("corp-directory-v1"); + properties.setUrl(endpoint); + properties.setStartTls(startTls); + properties.setBaseDn("dc=example,dc=com"); + properties.setUserSearchBase("ou=people"); + properties.setBindDn("cn=reader,dc=example,dc=com"); + properties.setBindPassword("reader-password"); + return new LdapProviderConfiguration( + properties, + new MockEnvironment().withProperty( + "spring.profiles.active", + "test")) + .requireResolved(); + } + + private void restartDirectory(InMemoryListenerConfig listener) + throws Exception { + client.close(); + directory.shutDown(true); + startDirectory(listener); + } + + private UnboundIdLdapDirectoryClient trustedTlsClient( + TlsMaterial tls) { + return new UnboundIdLdapDirectoryClient( + Clock.fixed(AUTHENTICATED_AT, ZoneOffset.UTC), + tls::clientSocketFactory); + } + + private String endpointHost(InetAddress address) { + String host = address.getHostAddress(); + return host.contains(":") ? "[" + host + "]" : host; + } + + private TlsMaterial tlsMaterial() throws Exception { + ObjectPair generated = + SelfSignedCertificateGenerator + .generateTemporarySelfSignedCertificate( + "SkillHub LDAP test", + "JKS"); + tlsKeyStore = generated.getFirst(); + char[] password = generated.getSecond(); + KeyStoreKeyManager keyManager = new KeyStoreKeyManager( + tlsKeyStore, + password, + "JKS", + "server-cert"); + TrustStoreTrustManager trustManager = + new TrustStoreTrustManager( + tlsKeyStore, + password, + "JKS", + true); + SSLUtil serverSsl = new SSLUtil(keyManager, trustManager); + return new TlsMaterial( + serverSsl.createSSLServerSocketFactory(), + serverSsl.createSSLSocketFactory(), + new SSLUtil(trustManager).createSSLSocketFactory()); + } + + private record TlsMaterial( + SSLServerSocketFactory serverSocketFactory, + SSLSocketFactory serverTlsSocketFactory, + SSLSocketFactory clientSocketFactory + ) { + } +} From 3c8b62a664befa40d7de03ef3190170cc10b89a5 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:54:49 +0800 Subject: [PATCH 2/2] fix(auth): harden LDAP identity assertions Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/24-ldap-ad-integration.md | 15 + scripts/ldap-smoke-test.sh | 287 ++++++++++++++++++ .../service/AccountMergeAppService.java | 16 +- .../skillhub/service/DirectAuthService.java | 14 +- .../service/IdentityLinkAppService.java | 14 +- .../service/ProviderLoginAppService.java | 21 ++ .../service/AccountMergeAppServiceTest.java | 1 + .../service/DirectAuthServiceTest.java | 8 +- .../service/IdentityLinkAppServiceTest.java | 3 + ...oviderAuthenticationFailureMapperTest.java | 45 +++ .../DefaultExternalIdentityLoginService.java | 36 +++ .../ExternalIdentityLoginService.java | 11 + .../identity/IdentityAssertionFactory.java | 103 ++++++- .../identity/IdentitySecurityAuditWriter.java | 24 ++ .../auth/ldap/LdapProviderConfiguration.java | 32 ++ ...faultExternalIdentityLoginServiceTest.java | 25 ++ .../IdentityAssertionFactoryTest.java | 88 ++++++ .../IdentitySecurityAuditWriterTest.java | 26 ++ .../ldap/LdapProviderConfigurationTest.java | 24 ++ 19 files changed, 775 insertions(+), 18 deletions(-) create mode 100755 scripts/ldap-smoke-test.sh diff --git a/docs/24-ldap-ad-integration.md b/docs/24-ldap-ad-integration.md index 95ee2b6ce..ec411e0f6 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 000000000..7b7ee3e1e --- /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 0d2afd3b6..20338f322 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 AccountMergeAppService( AccountMergeSessionManager sessionManager, AccountMergeProviderProofService providerProofService, IdentityProviderRegistry providerRegistry, + ProviderLoginAppService providerLoginAppService, IdentityLinkIntentService identityLinkIntentService, IdentityLinkSessionManager identityLinkSessionManager, @@ -84,6 +86,7 @@ public AccountMergeAppService( this.sessionManager = sessionManager; this.providerProofService = providerProofService; this.providerRegistry = providerRegistry; + this.providerLoginAppService = providerLoginAppService; this.identityLinkIntentService = identityLinkIntentService; this.identityLinkSessionManager = @@ -193,7 +196,8 @@ public AccountMergeCapabilitiesResponse capabilities( authenticate( route, username, - password), + password, + context), context); return new AccountMergePrimaryProofResponse( result.proof().method(), @@ -324,7 +328,8 @@ public AccountMergeIntentResponse createIntent( authenticate( route, username, - password), + password, + context), context)); } @@ -642,13 +647,18 @@ private boolean isFreshAuthenticationMethod( .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 f80fa6351..027c6bb06 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 PlatformPrincipal authenticate(String providerCode, var result = authenticate( route, username, - password); + password, + request); if (result == null) { throw new AuthFlowException( HttpStatus.UNAUTHORIZED, @@ -89,13 +90,18 @@ public PlatformPrincipal authenticate(String providerCode, 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 cc4595a80..fc937f284 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 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 IdentityLinkIntentResponse reauthenticateCredential( 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 IdentityLinkIntentResponse linkCredential( 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 IdentityLinkIntentResponse completeUnlink( 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 30bebc7bb..9b5c59059 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.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 @@ PlatformPrincipal authenticate( } } + 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 d1db210f3..006b5bb04 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 @@ void setUp() { 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 9b630ddd3..678cbf0f3 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 @@ void stableAdapterFailureIsMappedBeforeCoreOrSession() { .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 267d4adf8..81e154633 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 @@ private static final class Fixture { 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 @@ private static final class Fixture { 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 1dd738b48..3f01bbe4c 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 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 @@ void mapsStableProviderFailuresWithoutExposingUpstreamDetails() { ProviderAuthenticationFailureCode .UPSTREAM_INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED); + assertMapping( + ProviderAuthenticationFailureCode + .UPSTREAM_IDENTITY_NOT_FOUND, + HttpStatus.UNAUTHORIZED); assertMapping( ProviderAuthenticationFailureCode.REPLAY_DETECTED, HttpStatus.UNAUTHORIZED); @@ -45,6 +51,10 @@ void mapsStableProviderFailuresToIdentityLinkReasonCodes() { 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 @@ void mapsStableProviderFailuresToIdentityLinkReasonCodes() { 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 @@ private void assertIdentityLinkMapping( 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 42d8096d7..5fc347adc 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 @@ public IdentityLoginOutcome authenticate( } } + @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 0d76bdf72..3de6f359b 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 @@ IdentityLoginOutcome authenticate( 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 68ff34060..687afc5af 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 @@ 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 @@ private ExternalProfile createProfile( 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 @@ private Optional firstValue( 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 9174f4bbe..570586c62 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 @@ public void recordDenied( + 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 1a5e0d100..a1445381d 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 @@ ResolvedLdapProvider requireResolved() { String subjectType = optionalSubjectType( properties.getSubjectType()) .orElseGet(() -> defaultSubjectType(directoryType)); + validateSubjectMapping(subjectAttribute, subjectType); Optional usernameAttribute = optionalAttribute( properties.getUsernameAttribute()); Optional displayNameAttribute = optionalAttribute( @@ -311,6 +326,23 @@ private Optional optionalSubjectType(String value) { 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 a4fa2b2dc..deb489221 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.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 @@ void auditFailureDoesNotReplaceIdentityDenial() { .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 326784052..960931fc4 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 @@ void rejectsOversizedPrimarySubject() { .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 @@ private static ProviderDescriptor githubDescriptor() { ); } + 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 8338a2eca..81658d0c9 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.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 @@ void mapsOtherDenialsToGenericAuditAction() { "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 c9a159295..7c287e6cb 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 @@ void resolvedProviderStringDoesNotExposeBindPassword() { .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) {