feat(auth): add LDAP and Active Directory adapter

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 19:13:32 +08:00
parent 62a329848f
commit a35bdce3c6
40 changed files with 3292 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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://<skillhub-host>/api/v1/auth/cas/<provider-code>/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 模型名称 | 否 |

View file

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

View file

@ -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
# 必须与路径中的值一致。

View file

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

View file

@ -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: <secret-value>
```
生产环境建议设置 `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 语义变化必须执行
显式迁移,不能通过覆盖配置完成。

View file

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

View file

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

View file

@ -648,6 +648,7 @@ public class CasLoginAppService {
exception.getReasonCode();
return switch (code) {
case UPSTREAM_INVALID_CREDENTIALS,
UPSTREAM_IDENTITY_NOT_FOUND,
REPLAY_DETECTED ->
failure(CasLoginFailure.VALIDATION_FAILED);
case UPSTREAM_ACCESS_DENIED ->

View file

@ -21,6 +21,7 @@ final class ProviderAuthenticationFailureMapper {
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 @@ final class ProviderAuthenticationFailureMapper {
IdentityLinkFailureCode reasonCode =
switch (exception.getReasonCode()) {
case UPSTREAM_INVALID_CREDENTIALS,
UPSTREAM_IDENTITY_NOT_FOUND,
UPSTREAM_ACCESS_DENIED,
REPLAY_DETECTED ->
IdentityLinkFailureCode
@ -59,6 +61,7 @@ final class ProviderAuthenticationFailureMapper {
AccountMergeFailureCode reasonCode =
switch (exception.getReasonCode()) {
case UPSTREAM_INVALID_CREDENTIALS,
UPSTREAM_IDENTITY_NOT_FOUND,
UPSTREAM_ACCESS_DENIED,
REPLAY_DETECTED ->
AccountMergeFailureCode

View file

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

View file

@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View file

@ -125,7 +125,7 @@ final class IdentityAssertionFactory {
.filter(value -> !value.value().isBlank())
.map(value -> new EmailClaim(
value.value(),
toEmailAssurance(value.trust())
emailAssurance(descriptor, value)
.clampTo(descriptor.emailAssuranceLimit())));
Optional<URI> avatarUrl = firstValue(
@ -202,6 +202,16 @@ final class IdentityAssertionFactory {
};
}
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);

View file

@ -17,6 +17,7 @@ record ProviderDescriptor(
List<String> emailAttributes,
List<String> 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<String, SubjectCanonicalizer> subjectCanonicalizers,
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> 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());
}

View file

@ -274,6 +274,7 @@ class ReconciledIdentityProviderCatalog
definition.emailAttributes(),
definition.avatarAttributes(),
definition.emailAssuranceLimit(),
definition.authoritativeEmailSource(),
policy.provisioningMode(),
policy.profileSyncPolicy());
}

View file

@ -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<String, List<String>> 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<String, List<String>> copied =
new LinkedHashMap<>();
attributes.forEach((key, values) -> {
Objects.requireNonNull(key, "LDAP attribute key");
Objects.requireNonNull(values, "LDAP attribute values");
List<String> 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);
}
}

View file

@ -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<String, List<ProviderAttributeValue>> 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<String> 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<String, List<ProviderAttributeValue>> target,
String targetKey,
Optional<String> sourceKey,
Map<String, List<String>> source,
ProviderAttributeTrust trust) {
if (sourceKey.isEmpty()) {
return;
}
List<String> values = source.get(sourceKey.orElseThrow());
if (values == null) {
return;
}
List<ProviderAttributeValue> 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);
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.auth.ldap;
enum LdapDirectoryType {
OPENLDAP,
ACTIVE_DIRECTORY,
CUSTOM
}

View file

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

View file

@ -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<String> usernameAttribute = optionalAttribute(
properties.getUsernameAttribute());
Optional<String> displayNameAttribute = optionalAttribute(
properties.getDisplayNameAttribute());
Optional<String> emailAttribute = optionalAttribute(
properties.getEmailAttribute());
Optional<String> 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<String> 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<String> 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<String> 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<String> usernameAttribute;
private final Optional<String> displayNameAttribute;
private final Optional<String> emailAttribute;
private final Optional<String> 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<String> 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<String> usernameAttribute,
Optional<String> displayNameAttribute,
Optional<String> emailAttribute,
Optional<String> avatarUrlAttribute,
boolean emailAuthoritative,
Duration connectTimeout,
Duration readTimeout,
Duration poolWaitTimeout,
int maximumConcurrentRequests,
int maximumAttributeValues,
Set<String> 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<String> usernameAttribute() {
return usernameAttribute;
}
Optional<String> displayNameAttribute() {
return displayNameAttribute;
}
Optional<String> emailAttribute() {
return emailAttribute;
}
Optional<String> 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<String> requestedAttributes() {
return requestedAttributes;
}
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.auth.ldap;
enum LdapTransport {
PLAIN,
STARTTLS,
LDAPS
}

View file

@ -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<String, List<String>> 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<String> 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;
}
}

View file

@ -0,0 +1,4 @@
/**
* LDAP and Active Directory credential-provider integration.
*/
package com.iflytek.skillhub.auth.ldap;

View file

@ -6,6 +6,7 @@ package com.iflytek.skillhub.auth.provider;
*/
public enum ProviderAuthenticationFailureCode {
UPSTREAM_INVALID_CREDENTIALS,
UPSTREAM_IDENTITY_NOT_FOUND,
UPSTREAM_ACCESS_DENIED,
UPSTREAM_UNAVAILABLE,
UPSTREAM_MISCONFIGURED,

View file

@ -24,6 +24,7 @@ public record ProviderInstanceDefinition(
List<String> emailAttributes,
List<String> 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<String, SubjectNormalization> subjectNormalizations,
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> 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 record ProviderInstanceDefinition(
emailAttributes,
avatarAttributes,
emailAssuranceLimit,
false,
true);
}

View file

@ -84,6 +84,47 @@ class IdentityAssertionFactoryTest {
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(

View file

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

View file

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

View file

@ -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<File, char[]> 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
) {
}
}