mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat(auth): add LDAP and Active Directory adapter
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
62a329848f
commit
a35bdce3c6
40 changed files with 3292 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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" -}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 模型名称 | 否 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 必须与路径中的值一致。
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
180
docs/24-ldap-ad-integration.md
Normal file
180
docs/24-ldap-ad-integration.md
Normal 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 语义变化必须执行
|
||||
显式迁移,不能通过覆盖配置完成。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ class ReconciledIdentityProviderCatalog
|
|||
definition.emailAttributes(),
|
||||
definition.avatarAttributes(),
|
||||
definition.emailAssuranceLimit(),
|
||||
definition.authoritativeEmailSource(),
|
||||
policy.provisioningMode(),
|
||||
policy.profileSyncPolicy());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.ldap;
|
||||
|
||||
enum LdapDirectoryType {
|
||||
OPENLDAP,
|
||||
ACTIVE_DIRECTORY,
|
||||
CUSTOM
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.ldap;
|
||||
|
||||
enum LdapTransport {
|
||||
PLAIN,
|
||||
STARTTLS,
|
||||
LDAPS
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* LDAP and Active Directory credential-provider integration.
|
||||
*/
|
||||
package com.iflytek.skillhub.auth.ldap;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue