mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
chore(integration): stage CAS adapter on big-main
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
commit
e7bde3e177
76 changed files with 5740 additions and 105 deletions
|
|
@ -117,6 +117,28 @@ 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: 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.
|
||||
SKILLHUB_AUTH_CAS_ENABLED=false
|
||||
SKILLHUB_AUTH_CAS_PROVIDER_CODE=cas-main
|
||||
SKILLHUB_AUTH_CAS_DISPLAY_NAME=Corporate CAS
|
||||
SKILLHUB_AUTH_CAS_AUTHORITY=corp-cas
|
||||
SKILLHUB_AUTH_CAS_SERVER_URL=https://cas.example.com/cas
|
||||
SKILLHUB_AUTH_CAS_SERVICE_URL=https://skillhub.example.com/api/v1/auth/cas/cas-main/callback
|
||||
SKILLHUB_AUTH_CAS_PROTOCOL_VERSION=3.0
|
||||
SKILLHUB_AUTH_CAS_SUBJECT_TYPE=cas_principal
|
||||
SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING=false
|
||||
SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT=PT5S
|
||||
SKILLHUB_AUTH_CAS_READ_TIMEOUT=PT10S
|
||||
SKILLHUB_AUTH_CAS_STATE_TTL=PT5M
|
||||
SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES=1048576
|
||||
# Optional CAS attribute mappings. If subject is blank, the CAS principal is used.
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT=
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME=displayName
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL=mail
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL=
|
||||
|
||||
# Direct (username/password) authentication for environments without OAuth2.
|
||||
# To enable, set BOTH:
|
||||
# - SKILLHUB_AUTH_DIRECT_ENABLED=true (server: enables the /api/v1/auth/direct endpoint)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ firewall, with the same polish you'd expect from a public registry.
|
|||
|
||||
- 📖 **[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
|
||||
|
||||
## Highlights
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ SkillHub 是一个自托管平台,为团队提供私有的、受治理的智
|
|||
|
||||
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
|
||||
- 🛠️ **[开发者文档](https://zread.ai/iflytek/skillhub)** — 架构设计、API 参考、本地开发、部署运维等技术文档
|
||||
- 🔐 **[CAS 2.0/3.0 接入](./docs/23-cas-integration.md)** — Provider 配置、身份映射、部署与验证
|
||||
|
||||
## 核心特性
|
||||
|
||||
|
|
|
|||
|
|
@ -157,6 +157,34 @@ redis:
|
|||
|
||||
## 配置参考
|
||||
|
||||
### CAS 2.0/3.0
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
cas:
|
||||
enabled: true
|
||||
providerCode: cas-main
|
||||
displayName: Corporate CAS
|
||||
authority: corp-cas
|
||||
serverUrl: https://cas.example.com/cas
|
||||
serviceUrl: https://skills.example.com/api/v1/auth/cas/cas-main/callback
|
||||
protocolVersion: "3.0"
|
||||
subjectType: cas_principal
|
||||
connectTimeout: PT5S
|
||||
readTimeout: PT10S
|
||||
stateTtl: PT5M
|
||||
maxResponseBytes: 1048576
|
||||
attributes:
|
||||
subject: ""
|
||||
displayName: displayName
|
||||
email: mail
|
||||
avatarUrl: ""
|
||||
```
|
||||
|
||||
`serviceUrl` 必须使用 HTTPS,并以当前 `providerCode` 对应的精确 callback 结尾。
|
||||
普通 CAS email attribute 只按 asserted 处理。完整说明见
|
||||
[`docs/23-cas-integration.md`](../../docs/23-cas-integration.md)。
|
||||
|
||||
### 副本数配置
|
||||
|
||||
| 参数 | 描述 | 默认值 |
|
||||
|
|
|
|||
|
|
@ -54,4 +54,20 @@ data:
|
|||
device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }}
|
||||
auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }}
|
||||
auth-direct-provider: {{ .Values.auth.direct.provider | 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 }}
|
||||
auth-cas-authority: {{ .Values.auth.cas.authority | quote }}
|
||||
auth-cas-server-url: {{ .Values.auth.cas.serverUrl | quote }}
|
||||
auth-cas-service-url: {{ .Values.auth.cas.serviceUrl | quote }}
|
||||
auth-cas-protocol-version: {{ .Values.auth.cas.protocolVersion | quote }}
|
||||
auth-cas-subject-type: {{ .Values.auth.cas.subjectType | quote }}
|
||||
auth-cas-connect-timeout: {{ .Values.auth.cas.connectTimeout | quote }}
|
||||
auth-cas-read-timeout: {{ .Values.auth.cas.readTimeout | quote }}
|
||||
auth-cas-state-ttl: {{ .Values.auth.cas.stateTtl | quote }}
|
||||
auth-cas-max-response-bytes: {{ .Values.auth.cas.maxResponseBytes | quote }}
|
||||
auth-cas-attribute-subject: {{ .Values.auth.cas.attributes.subject | quote }}
|
||||
auth-cas-attribute-display-name: {{ .Values.auth.cas.attributes.displayName | quote }}
|
||||
auth-cas-attribute-email: {{ .Values.auth.cas.attributes.email | quote }}
|
||||
auth-cas-attribute-avatar-url: {{ .Values.auth.cas.attributes.avatarUrl | quote }}
|
||||
builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }}
|
||||
|
|
|
|||
|
|
@ -295,6 +295,86 @@ spec:
|
|||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-direct-enabled
|
||||
- name: SKILLHUB_AUTH_CAS_ENABLED
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-enabled
|
||||
- name: SKILLHUB_AUTH_CAS_PROVIDER_CODE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-provider-code
|
||||
- name: SKILLHUB_AUTH_CAS_DISPLAY_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-display-name
|
||||
- name: SKILLHUB_AUTH_CAS_AUTHORITY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-authority
|
||||
- name: SKILLHUB_AUTH_CAS_SERVER_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-server-url
|
||||
- name: SKILLHUB_AUTH_CAS_SERVICE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-service-url
|
||||
- name: SKILLHUB_AUTH_CAS_PROTOCOL_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-protocol-version
|
||||
- name: SKILLHUB_AUTH_CAS_SUBJECT_TYPE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-subject-type
|
||||
- name: SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-connect-timeout
|
||||
- name: SKILLHUB_AUTH_CAS_READ_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-read-timeout
|
||||
- name: SKILLHUB_AUTH_CAS_STATE_TTL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-state-ttl
|
||||
- name: SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-max-response-bytes
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-attribute-subject
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-attribute-display-name
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-attribute-email
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-attribute-avatar-url
|
||||
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@
|
|||
{{- if and .Values.auth.direct.enabled (not .Values.auth.direct.provider) -}}
|
||||
{{- fail "auth.direct.enabled=true requires auth.direct.provider" -}}
|
||||
{{- end -}}
|
||||
{{- if .Values.auth.cas.enabled -}}
|
||||
{{- if not .Values.auth.cas.serverUrl -}}
|
||||
{{- fail "auth.cas.enabled=true requires auth.cas.serverUrl" -}}
|
||||
{{- end -}}
|
||||
{{- if not .Values.auth.cas.serviceUrl -}}
|
||||
{{- fail "auth.cas.enabled=true requires auth.cas.serviceUrl" -}}
|
||||
{{- end -}}
|
||||
{{- if not (regexMatch "^https://[^[:space:]]+$" .Values.auth.cas.serverUrl) -}}
|
||||
{{- fail "auth.cas.serverUrl must be an absolute HTTPS URL" -}}
|
||||
{{- end -}}
|
||||
{{- if not (regexMatch "^https://[^[:space:]?#]+$" .Values.auth.cas.serviceUrl) -}}
|
||||
{{- fail "auth.cas.serviceUrl must be an absolute HTTPS URL without query or fragment" -}}
|
||||
{{- end -}}
|
||||
{{- $expectedCasCallback := printf "/api/v1/auth/cas/%s/callback" .Values.auth.cas.providerCode -}}
|
||||
{{- if not (hasSuffix $expectedCasCallback .Values.auth.cas.serviceUrl) -}}
|
||||
{{- fail (printf "auth.cas.serviceUrl must end with %s" $expectedCasCallback) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if and .Values.ingress.enabled (not .Values.server.service.enabled) -}}
|
||||
{{- fail "ingress.enabled=true requires server.service.enabled=true" -}}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,16 @@ render device "$CHART_DIR" \
|
|||
--show-only templates/configmap.yaml >"$TMP_DIR/device.yaml"
|
||||
grep -Fq 'device-auth-verification-uri: "https://skills.example.com/cli/auth"' "$TMP_DIR/device.yaml"
|
||||
|
||||
render cas "$CHART_DIR" \
|
||||
--set auth.cas.enabled=true \
|
||||
--set auth.cas.serverUrl=https://cas.example.com/cas \
|
||||
--set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/cas-main/callback \
|
||||
--show-only templates/configmap.yaml \
|
||||
--show-only templates/server-deployment.yaml >"$TMP_DIR/cas.yaml"
|
||||
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 tls "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
|
||||
|
|
@ -265,6 +275,17 @@ 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 cas-without-server \
|
||||
--set auth.cas.enabled=true \
|
||||
--set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/cas-main/callback
|
||||
assert_rejected cas-with-insecure-server \
|
||||
--set auth.cas.enabled=true \
|
||||
--set auth.cas.serverUrl=http://cas.example.com/cas \
|
||||
--set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/cas-main/callback
|
||||
assert_rejected cas-with-wrong-callback \
|
||||
--set auth.cas.enabled=true \
|
||||
--set auth.cas.serverUrl=https://cas.example.com/cas \
|
||||
--set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/other/callback
|
||||
assert_rejected ingress-without-server-service --set ingress.enabled=true --set server.service.enabled=false
|
||||
assert_rejected ingress-without-web-service --set ingress.enabled=true --set web.service.enabled=false
|
||||
assert_rejected multi-without-rwx --set server.replicaCount=2
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"auth": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["direct"],
|
||||
"required": ["direct", "cas"],
|
||||
"properties": {
|
||||
"direct": {
|
||||
"type": "object",
|
||||
|
|
@ -31,6 +31,36 @@
|
|||
"enabled": { "type": "boolean" },
|
||||
"provider": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"cas": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "providerCode", "displayName", "authority", "serverUrl", "serviceUrl", "protocolVersion", "subjectType", "connectTimeout", "readTimeout", "stateTtl", "maxResponseBytes", "attributes"],
|
||||
"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}$" },
|
||||
"serverUrl": { "type": "string" },
|
||||
"serviceUrl": { "type": "string" },
|
||||
"protocolVersion": { "enum": ["2.0", "3.0"] },
|
||||
"subjectType": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" },
|
||||
"connectTimeout": { "type": "string", "minLength": 1 },
|
||||
"readTimeout": { "type": "string", "minLength": 1 },
|
||||
"stateTtl": { "type": "string", "minLength": 1 },
|
||||
"maxResponseBytes": { "type": "integer", "minimum": 1024, "maximum": 1048576 },
|
||||
"attributes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["subject", "displayName", "email", "avatarUrl"],
|
||||
"properties": {
|
||||
"subject": { "type": "string" },
|
||||
"displayName": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"avatarUrl": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,24 @@ auth:
|
|||
direct:
|
||||
enabled: true
|
||||
provider: local
|
||||
cas:
|
||||
enabled: false
|
||||
providerCode: cas-main
|
||||
displayName: "Corporate CAS"
|
||||
authority: corp-cas
|
||||
serverUrl: ""
|
||||
serviceUrl: ""
|
||||
protocolVersion: "3.0"
|
||||
subjectType: cas_principal
|
||||
connectTimeout: PT5S
|
||||
readTimeout: PT10S
|
||||
stateTtl: PT5M
|
||||
maxResponseBytes: 1048576
|
||||
attributes:
|
||||
subject: ""
|
||||
displayName: displayName
|
||||
email: mail
|
||||
avatarUrl: ""
|
||||
|
||||
builtinSkills:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -88,6 +88,23 @@ services:
|
|||
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
|
||||
SKILLHUB_SECURITY_SCANNER_MODE: upload
|
||||
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
|
||||
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}
|
||||
SKILLHUB_AUTH_CAS_AUTHORITY: ${SKILLHUB_AUTH_CAS_AUTHORITY:-}
|
||||
SKILLHUB_AUTH_CAS_SERVER_URL: ${SKILLHUB_AUTH_CAS_SERVER_URL:-}
|
||||
SKILLHUB_AUTH_CAS_SERVICE_URL: ${SKILLHUB_AUTH_CAS_SERVICE_URL:-}
|
||||
SKILLHUB_AUTH_CAS_PROTOCOL_VERSION: ${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:-3.0}
|
||||
SKILLHUB_AUTH_CAS_SUBJECT_TYPE: ${SKILLHUB_AUTH_CAS_SUBJECT_TYPE:-cas_principal}
|
||||
SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING: ${SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING:-false}
|
||||
SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT: ${SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT:-PT5S}
|
||||
SKILLHUB_AUTH_CAS_READ_TIMEOUT: ${SKILLHUB_AUTH_CAS_READ_TIMEOUT:-PT10S}
|
||||
SKILLHUB_AUTH_CAS_STATE_TTL: ${SKILLHUB_AUTH_CAS_STATE_TTL:-PT5M}
|
||||
SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES: ${SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES:-1048576}
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT:-}
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME:-}
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL:-}
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL:-}
|
||||
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
|
||||
SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-text}
|
||||
SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ cp secret.yaml.example secret.yaml
|
|||
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
|
||||
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
|
||||
|
||||
CAS 2.0/3.0 不需要额外 Secret。启用时修改 `base/configmap.yaml` 中的
|
||||
`auth-cas-*` 配置;`auth-cas-service-url` 必须是精确 callback:
|
||||
|
||||
```text
|
||||
https://<skillhub-host>/api/v1/auth/cas/<provider-code>/callback
|
||||
```
|
||||
|
||||
完整字段、身份映射和验证步骤见
|
||||
[`docs/23-cas-integration.md`](../../docs/23-cas-integration.md)。
|
||||
|
||||
### 3. 选择部署方式
|
||||
|
||||
**方式一:完整部署(包含 PostgreSQL + Redis)**
|
||||
|
|
|
|||
|
|
@ -168,6 +168,88 @@ spec:
|
|||
name: skillhub-config
|
||||
key: session-cookie-secure
|
||||
|
||||
# CAS 2.0/3.0 browser login
|
||||
- name: SKILLHUB_AUTH_CAS_ENABLED
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-enabled
|
||||
- name: SKILLHUB_AUTH_CAS_PROVIDER_CODE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-provider-code
|
||||
- name: SKILLHUB_AUTH_CAS_DISPLAY_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-display-name
|
||||
- name: SKILLHUB_AUTH_CAS_AUTHORITY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-authority
|
||||
- name: SKILLHUB_AUTH_CAS_SERVER_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-server-url
|
||||
- name: SKILLHUB_AUTH_CAS_SERVICE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-service-url
|
||||
- name: SKILLHUB_AUTH_CAS_PROTOCOL_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-protocol-version
|
||||
- name: SKILLHUB_AUTH_CAS_SUBJECT_TYPE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-subject-type
|
||||
- name: SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-connect-timeout
|
||||
- name: SKILLHUB_AUTH_CAS_READ_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-read-timeout
|
||||
- name: SKILLHUB_AUTH_CAS_STATE_TTL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-state-ttl
|
||||
- name: SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-max-response-bytes
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-attribute-subject
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-attribute-display-name
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-attribute-email
|
||||
- name: SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-cas-attribute-avatar-url
|
||||
|
||||
# Bootstrap Admin (non-sensitive from ConfigMap)
|
||||
- name: BOOTSTRAP_ADMIN_ENABLED
|
||||
valueFrom:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,26 @@ data:
|
|||
# Session 配置
|
||||
# HTTP 环境设为 false,HTTPS 环境设为 true
|
||||
session-cookie-secure: "false"
|
||||
|
||||
# CAS 2.0/3.0 登录(默认关闭)
|
||||
# service-url 必须是浏览器可访问的精确回调地址,且 provider code
|
||||
# 必须与路径中的值一致。
|
||||
auth-cas-enabled: "false"
|
||||
auth-cas-provider-code: cas-main
|
||||
auth-cas-display-name: Corporate CAS
|
||||
auth-cas-authority: corp-cas
|
||||
auth-cas-server-url: https://cas.example.com/cas
|
||||
auth-cas-service-url: https://skillhub.example.com/api/v1/auth/cas/cas-main/callback
|
||||
auth-cas-protocol-version: "3.0"
|
||||
auth-cas-subject-type: cas_principal
|
||||
auth-cas-connect-timeout: PT5S
|
||||
auth-cas-read-timeout: PT10S
|
||||
auth-cas-state-ttl: PT5M
|
||||
auth-cas-max-response-bytes: "1048576"
|
||||
auth-cas-attribute-subject: ""
|
||||
auth-cas-attribute-display-name: displayName
|
||||
auth-cas-attribute-email: mail
|
||||
auth-cas-attribute-avatar-url: ""
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
|
|
|
|||
|
|
@ -1633,10 +1633,12 @@ Provider capability 检查、Binding/Subject 唯一性、账号资格和审计
|
|||
- 操作类型、目标 Provider、可选目标 Binding;
|
||||
- 固定过期时间和一次性状态。
|
||||
|
||||
当前账号证明与目标 Provider 证明必须分开。Browser Provider 使用现有 OAuth state
|
||||
校验并保留主 Platform Session;Credential Provider 只在 Adapter 中校验凭据,
|
||||
只把 `ProviderAuthenticationResult` 交给核心。密码、OAuth code/token、ticket、
|
||||
Cookie、原始 Session ID/nonce 和 proof 不进入 DTO、数据库、审计或日志。
|
||||
当前账号证明与目标 Provider 证明必须分开。Browser Provider 使用协议自身的一次性
|
||||
state 校验并保留主 Platform Session:OAuth 复用 Spring Security authorization request
|
||||
state,CAS 使用 Redis 原子消费 state 并把 Ticket 绑定到精确 service URL。Credential
|
||||
Provider 只在 Adapter 中校验凭据,只把 `ProviderAuthenticationResult` 交给核心。
|
||||
密码、OAuth code/token、CAS ticket、Cookie、原始 Session ID/nonce 和 proof 不进入
|
||||
DTO、数据库、审计或日志。
|
||||
|
||||
公开 API:
|
||||
|
||||
|
|
@ -1912,15 +1914,36 @@ GET login start
|
|||
|
||||
要求:
|
||||
|
||||
- 使用成熟 CAS Client 或 Spring Security CAS 集成,不自行维护不安全 XML parser。
|
||||
- 优先使用成熟 CAS Client 或 Spring Security CAS 集成;如果兼容性与安全 spike 证明
|
||||
其违反本文更高优先级的不变量(例如记录 Ticket/完整响应,或 XML parser 不能
|
||||
fail closed),可以使用最小、受限且经过安全 Review 的内部 transport/parser,并
|
||||
必须在设计和协议测试中记录证据。
|
||||
- Ticket validation 必须绑定发起时精确 service URL。
|
||||
- state 和 Ticket 一次性消费。
|
||||
- CAS Browser Flow 必须接入统一 Identity Link intent,支持当前账号重新认证和目标
|
||||
Provider 绑定;不得在 CAS Adapter 内直接写 Binding。
|
||||
- CAS 2 XML 禁止 XXE。
|
||||
- CAS 3 JSON/XML 错误正确分类。
|
||||
- principal 或 immutable attribute 必须明确配置为稳定 Subject。
|
||||
- CAS email attribute 默认不是 verified。
|
||||
- 强制 HTTPS 的规则要支持明确的本地测试例外,生产 fail closed。
|
||||
|
||||
当前 Spring Boot 3.2 / Java 21 兼容性 spike 的结论:
|
||||
|
||||
- Spring Security CAS 使用 Apereo Java CAS Client 的 `TicketValidator`。
|
||||
- Apereo Java CAS Client 4.1.1 的 `AbstractUrlBasedTicketValidator` 在 DEBUG 记录包含
|
||||
Ticket 的完整 validation URL 和完整上游响应,违反 7.6 和 17.2 节。
|
||||
- 同版本 `Cas20ServiceTicketValidator.extractCustomAttributes` 的独立 SAX 路径没有显式
|
||||
关闭全部外部实体能力,不能作为本项目 CAS 2 属性解析的安全边界。
|
||||
- 因此 PR 9 使用 JDK `HttpClient` 的受限 transport 和显式 hardening 的 JSON/XML parser:
|
||||
禁止 redirect、限制连接/请求超时与响应大小、XML 禁止 DOCTYPE/外部实体/DTD/schema,
|
||||
且 Ticket、URL、完整响应和属性不进入日志或异常。
|
||||
|
||||
参考:
|
||||
|
||||
- [Apereo validator logging](https://github.com/apereo/java-cas-client/blob/cas-client-4.1.1/cas-client-core/src/main/java/org/apereo/cas/client/validation/AbstractUrlBasedTicketValidator.java)
|
||||
- [Apereo CAS 2 attribute parser](https://github.com/apereo/java-cas-client/blob/cas-client-4.1.1/cas-client-core/src/main/java/org/apereo/cas/client/validation/Cas20ServiceTicketValidator.java)
|
||||
|
||||
### 14.5 SAML 2.0
|
||||
|
||||
首选路径:
|
||||
|
|
@ -2546,7 +2569,8 @@ DingTalk 的目标支持等级是 Native Tier 1。实现可以来自社区,但
|
|||
#### PR 9:CAS Adapter
|
||||
|
||||
- 从 #464 复用协议和测试思路。
|
||||
- 使用成熟 CAS Client。
|
||||
- 按 14.4 节 spike 结论使用成熟 Client,或使用经过安全 Review、不会泄露 Ticket 且对
|
||||
XML/响应边界 fail closed 的最小内部 Client。
|
||||
- 不复用仅把 `OAuthClaims` 泛化为浅 `IdentityClaims` 的核心方式。
|
||||
|
||||
LDAP 和 DingTalk 不要求必须由维护者亲自编码。更准确的规则是:
|
||||
|
|
|
|||
179
docs/23-cas-integration.md
Normal file
179
docs/23-cas-integration.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# CAS 2.0/3.0 接入指南
|
||||
|
||||
本文说明如何把一个 CAS Provider Instance 接入 SkillHub 统一身份核心。CAS 登录默认关闭,
|
||||
配置不完整或 Authority 状态异常时不会出现在登录方法目录中。
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- SkillHub 的公开入口和 CAS Server 使用 HTTPS。
|
||||
- Redis 可用。CAS state 使用 Redis 原子消费,以支持多 Pod 和重放保护。
|
||||
- CAS Server 已允许下面的精确 Service URL:
|
||||
|
||||
```text
|
||||
https://<skillhub-host>/api/v1/auth/cas/<provider-code>/callback
|
||||
```
|
||||
|
||||
- 已确认可作为稳定身份键的 CAS principal 或 immutable attribute。
|
||||
|
||||
`provider-code` 和 `authority` 是持久身份绑定的一部分。产生 Identity Binding 后不能通过
|
||||
普通配置变更切换到另一个身份域。
|
||||
|
||||
## 2. 配置
|
||||
|
||||
| 环境变量 | 必填 | 默认值 | 说明 |
|
||||
|---|---:|---|---|
|
||||
| `SKILLHUB_AUTH_CAS_ENABLED` | 是 | `false` | 显式启用 CAS |
|
||||
| `SKILLHUB_AUTH_CAS_PROVIDER_CODE` | 是 | `cas` | Provider Instance code,需长期稳定 |
|
||||
| `SKILLHUB_AUTH_CAS_DISPLAY_NAME` | 是 | `CAS` | 登录页展示名 |
|
||||
| `SKILLHUB_AUTH_CAS_AUTHORITY` | 是 | 空 | 稳定 CAS 身份域 ID |
|
||||
| `SKILLHUB_AUTH_CAS_SERVER_URL` | 是 | 空 | CAS Server 根地址,例如 `https://cas.example.com/cas` |
|
||||
| `SKILLHUB_AUTH_CAS_SERVICE_URL` | 是 | 空 | 精确 SkillHub callback,不含 query/fragment |
|
||||
| `SKILLHUB_AUTH_CAS_PROTOCOL_VERSION` | 是 | `3.0` | `2.0` 或 `3.0` |
|
||||
| `SKILLHUB_AUTH_CAS_SUBJECT_TYPE` | 是 | `cas_principal` | 统一身份核心中的 Subject type |
|
||||
| `SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT` | 否 | 空 | 为空时使用 CAS principal;否则使用唯一 immutable attribute |
|
||||
| `SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME` | 否 | 空 | display name attribute |
|
||||
| `SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL` | 否 | 空 | email attribute,固定按 asserted 处理 |
|
||||
| `SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL` | 否 | 空 | avatar URL attribute |
|
||||
| `SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT` | 否 | `PT5S` | 连接超时 |
|
||||
| `SKILLHUB_AUTH_CAS_READ_TIMEOUT` | 否 | `PT10S` | 请求总超时 |
|
||||
| `SKILLHUB_AUTH_CAS_STATE_TTL` | 否 | `PT5M` | 登录 state 有效期,最大 15 分钟 |
|
||||
| `SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES` | 否 | `1048576` | validation 响应上限,1 KiB–1 MiB |
|
||||
|
||||
CAS 返回的普通 email attribute 不是 verified email,不能用于
|
||||
`EMAIL_DOMAIN` 准入或静默账号绑定。
|
||||
|
||||
### Docker Compose
|
||||
|
||||
编辑 `.env.release`:
|
||||
|
||||
```dotenv
|
||||
SKILLHUB_AUTH_CAS_ENABLED=true
|
||||
SKILLHUB_AUTH_CAS_PROVIDER_CODE=cas-main
|
||||
SKILLHUB_AUTH_CAS_DISPLAY_NAME=Corporate CAS
|
||||
SKILLHUB_AUTH_CAS_AUTHORITY=corp-cas
|
||||
SKILLHUB_AUTH_CAS_SERVER_URL=https://cas.example.com/cas
|
||||
SKILLHUB_AUTH_CAS_SERVICE_URL=https://skills.example.com/api/v1/auth/cas/cas-main/callback
|
||||
SKILLHUB_AUTH_CAS_PROTOCOL_VERSION=3.0
|
||||
SKILLHUB_AUTH_CAS_SUBJECT_TYPE=cas_principal
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME=displayName
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL=mail
|
||||
```
|
||||
|
||||
启动前运行:
|
||||
|
||||
```bash
|
||||
./scripts/validate-release-config.sh .env.release
|
||||
```
|
||||
|
||||
### Helm
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
cas:
|
||||
enabled: true
|
||||
providerCode: cas-main
|
||||
displayName: Corporate CAS
|
||||
authority: corp-cas
|
||||
serverUrl: https://cas.example.com/cas
|
||||
serviceUrl: https://skills.example.com/api/v1/auth/cas/cas-main/callback
|
||||
protocolVersion: "3.0"
|
||||
subjectType: cas_principal
|
||||
connectTimeout: PT5S
|
||||
readTimeout: PT10S
|
||||
stateTtl: PT5M
|
||||
maxResponseBytes: 1048576
|
||||
attributes:
|
||||
subject: ""
|
||||
displayName: displayName
|
||||
email: mail
|
||||
avatarUrl: ""
|
||||
```
|
||||
|
||||
Chart 在渲染阶段拒绝 HTTP endpoint、缺失 URL 和 provider code 不匹配的 callback。
|
||||
|
||||
### Kustomize
|
||||
|
||||
修改 `deploy/k8s/base/configmap.yaml` 中的 `auth-cas-*` 字段,然后重新应用 overlay。
|
||||
CAS 配置不包含 client secret;若企业扩展引入凭证,必须放入 Kubernetes Secret,不能放
|
||||
在 ConfigMap。
|
||||
|
||||
## 3. 身份映射
|
||||
|
||||
默认映射:
|
||||
|
||||
```text
|
||||
primary subject = CAS principal
|
||||
subject type = cas_principal
|
||||
email assurance = PROVIDER_ASSERTED
|
||||
aliases = none
|
||||
```
|
||||
|
||||
如果 CAS principal 可能随用户名变更,必须配置
|
||||
`SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT` 指向 CAS Server 明确定义且实际返回的 immutable
|
||||
attribute,并为该属性选择稳定的 `subject-type`。属性缺失、多值或空值时登录 fail closed。
|
||||
|
||||
不要把 email、display name 或临时用户名当作稳定 Subject。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
1. 匿名请求登录方法目录:
|
||||
|
||||
```bash
|
||||
curl -fsS 'https://skills.example.com/api/v1/auth/methods'
|
||||
```
|
||||
|
||||
只在 Provider 为 `READY` 时应出现 `CAS_REDIRECT`。
|
||||
|
||||
2. 在浏览器点击 CAS 登录,确认重定向到 CAS `/login`,其 `service` 解码后与配置的
|
||||
callback 加一次性 `state` 完全一致。
|
||||
3. 完成 CAS 登录,确认返回原始 `returnTo` 或默认页面,并能请求
|
||||
`/api/v1/auth/me`。
|
||||
4. 再次请求相同 callback,必须得到 `casInvalidState` 或 CAS Ticket 验证失败,不能建立
|
||||
第二个 Session。
|
||||
5. 检查应用日志中不包含 `ST-` Ticket、validation URL、上游完整响应或用户属性。
|
||||
6. 禁用 Provider 后,方法目录不再显示 CAS,且点击旧 URL 不应连接 CAS Server。
|
||||
7. 在账号安全页验证 CAS Identity Link:已绑定 CAS 可以完成 fresh reauthentication,
|
||||
READY intent 可以通过目标 CAS 创建 Binding;失败后应回到同一 intent,成功后不能重放。
|
||||
|
||||
建议分别验证 CAS 2 XML、CAS 3 JSON、CAS 3 XML fallback、无效 Ticket、错误 Service、
|
||||
超时、TLS 失败、XXE、超大响应和 Redis 不可用。
|
||||
|
||||
CAS callback 按协议会在 query string 中携带一次性 `ticket` 和 `state`。官方 Web
|
||||
镜像不记录 query string 或 Referer,应用请求日志会对它们进行隐藏;如果前面还有
|
||||
Ingress、负载均衡、WAF、APM 或其他反向代理,必须将该 callback 路径的 query string
|
||||
和 Referer 关闭记录或至少对 `ticket`、`state` 脱敏。上线前应使用唯一测试 Ticket
|
||||
检查完整日志链路,确认没有原文残留。重复 state 会分类为 `REPLAY_DETECTED` 并写入
|
||||
不含 state/Ticket 的安全审计。
|
||||
|
||||
## 5. 升级与回滚
|
||||
|
||||
- 新配置默认关闭,不影响现有本地密码、GitHub、GitLab 或 OIDC 登录。
|
||||
- 本次接入不修改 `PlatformPrincipal` 或现有 Session 序列化结构。
|
||||
- 旧版本回滚后会忽略 CAS 环境变量;现有非 CAS 登录仍可使用。
|
||||
- CAS 已产生 Binding 后回滚会暂时失去 CAS 登录入口,但不会删除账号、Binding 或业务
|
||||
数据。
|
||||
- 修改 CAS endpoint 可以保持同一 Authority;修改 Authority 必须走统一身份设计中的
|
||||
显式 Authority 迁移流程。
|
||||
|
||||
## 6. 安全实现说明
|
||||
|
||||
实现阶段验证了 Spring Security CAS 所依赖的 Apereo Java CAS Client。其 4.1.1
|
||||
`AbstractUrlBasedTicketValidator` 仍会在 DEBUG 记录包含 Ticket 的 validation URL 和完整
|
||||
响应;CAS 2 自定义属性解析路径也没有满足本项目要求的全部 XML parser fail-closed
|
||||
设置。因此当前实现没有调用该库的 URL validator,而是使用受限 JDK HTTP transport 和
|
||||
严格 parser:
|
||||
|
||||
- 禁止 redirect;
|
||||
- 明确连接、请求和响应大小上限;
|
||||
- XML 禁止 DOCTYPE、外部实体、外部 DTD 和外部 schema;
|
||||
- Ticket、完整响应和属性不写日志或异常;
|
||||
- CAS 3 优先 JSON,并支持受限 XML fallback。
|
||||
|
||||
上游参考:
|
||||
|
||||
- [Spring Security CAS reference](https://docs.spring.io/spring-security/reference/servlet/authentication/cas.html)
|
||||
- [Apereo validator logging](https://github.com/apereo/java-cas-client/blob/cas-client-4.1.1/cas-client-core/src/main/java/org/apereo/cas/client/validation/AbstractUrlBasedTicketValidator.java)
|
||||
- [Apereo CAS 2 attribute parser](https://github.com/apereo/java-cas-client/blob/cas-client-4.1.1/cas-client-core/src/main/java/org/apereo/cas/client/validation/Cas20ServiceTicketValidator.java)
|
||||
|
||||
如果上游版本修复这些问题,可以在保持错误分类、响应上限和无敏感日志测试的前提下重新
|
||||
评估替换 transport/parser。
|
||||
|
|
@ -98,4 +98,16 @@ done
|
|||
assert_proto "$TRUSTED_PROXY" http
|
||||
assert_proto "$TRUSTED_PROXY" http "https,http"
|
||||
|
||||
LOG_TICKET="ST-nginx-log-secret-$$"
|
||||
LOG_STATE="nginx-state-secret-$$"
|
||||
docker exec "$DEFAULT_PROXY" wget -qO- \
|
||||
--header="Referer: https://skillhub.test/api/v1/auth/cas/cas-main/callback?ticket=$LOG_TICKET&state=$LOG_STATE" \
|
||||
"http://127.0.0.1/api/proto?ticket=$LOG_TICKET&state=$LOG_STATE" \
|
||||
>/dev/null
|
||||
NGINX_ACCESS_LOG="$(docker logs "$DEFAULT_PROXY" 2>&1)"
|
||||
[[ "$NGINX_ACCESS_LOG" != *"$LOG_TICKET"* ]] \
|
||||
|| fail "nginx access log exposed a CAS ticket"
|
||||
[[ "$NGINX_ACCESS_LOG" != *"$LOG_STATE"* ]] \
|
||||
|| fail "nginx access log exposed a CAS state"
|
||||
|
||||
echo "nginx-forwarded-proto-test passed"
|
||||
|
|
|
|||
|
|
@ -222,9 +222,47 @@ 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_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_CAS_ENABLED:-false}" = "true" ]; then
|
||||
require_non_empty SKILLHUB_AUTH_CAS_PROVIDER_CODE
|
||||
require_non_empty SKILLHUB_AUTH_CAS_DISPLAY_NAME
|
||||
require_non_empty SKILLHUB_AUTH_CAS_AUTHORITY
|
||||
require_non_empty SKILLHUB_AUTH_CAS_SERVER_URL
|
||||
require_non_empty SKILLHUB_AUTH_CAS_SERVICE_URL
|
||||
validate_url SKILLHUB_AUTH_CAS_SERVER_URL
|
||||
validate_url SKILLHUB_AUTH_CAS_SERVICE_URL
|
||||
case "${SKILLHUB_AUTH_CAS_SERVER_URL:-}" in
|
||||
*\?*|*\#*) error "SKILLHUB_AUTH_CAS_SERVER_URL must not contain a query or fragment" ;;
|
||||
esac
|
||||
case "${SKILLHUB_AUTH_CAS_SERVICE_URL:-}" in
|
||||
*\?*|*\#*) error "SKILLHUB_AUTH_CAS_SERVICE_URL must not contain a query or fragment" ;;
|
||||
esac
|
||||
case "${SKILLHUB_AUTH_CAS_SERVER_URL:-}" in
|
||||
https://*) ;;
|
||||
*) error "SKILLHUB_AUTH_CAS_SERVER_URL must use https in release deployments" ;;
|
||||
esac
|
||||
case "${SKILLHUB_AUTH_CAS_SERVICE_URL:-}" in
|
||||
https://*) ;;
|
||||
*) error "SKILLHUB_AUTH_CAS_SERVICE_URL must use https in release deployments" ;;
|
||||
esac
|
||||
case "${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:-3.0}" in
|
||||
2.0|3.0) ;;
|
||||
*) error "SKILLHUB_AUTH_CAS_PROTOCOL_VERSION must be 2.0 or 3.0" ;;
|
||||
esac
|
||||
expected_cas_callback="/api/v1/auth/cas/${SKILLHUB_AUTH_CAS_PROVIDER_CODE}/callback"
|
||||
case "${SKILLHUB_AUTH_CAS_SERVICE_URL:-}" in
|
||||
*"$expected_cas_callback") ;;
|
||||
*) error "SKILLHUB_AUTH_CAS_SERVICE_URL must end with $expected_cas_callback" ;;
|
||||
esac
|
||||
if [ "${SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING:-false}" = "true" ]; then
|
||||
error "SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING cannot be true in release deployments"
|
||||
fi
|
||||
fi
|
||||
|
||||
validate_port POSTGRES_PORT
|
||||
validate_port REDIS_PORT
|
||||
validate_port API_PORT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.service.CasLoginAppService;
|
||||
import com.iflytek.skillhub.service.CasLoginFailure;
|
||||
import com.iflytek.skillhub.service.CasLoginFlowException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.net.URI;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/**
|
||||
* CAS browser transport. Protocol verification and identity resolution are
|
||||
* delegated to application and auth services.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/api/v1/auth/cas/{providerCode}")
|
||||
public class CasLoginController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
CasLoginController.class);
|
||||
|
||||
private final CasLoginAppService loginAppService;
|
||||
|
||||
public CasLoginController(
|
||||
CasLoginAppService loginAppService) {
|
||||
this.loginAppService = loginAppService;
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
@Operation(summary = "Start a CAS browser login")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "302",
|
||||
description = "Redirect to the configured CAS login endpoint")
|
||||
})
|
||||
public ResponseEntity<Void> login(
|
||||
@PathVariable String providerCode,
|
||||
@RequestParam(required = false) String returnTo,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
return redirect(loginAppService.begin(
|
||||
providerCode,
|
||||
returnTo,
|
||||
request));
|
||||
} catch (CasLoginFlowException exception) {
|
||||
return redirect(failureTarget(exception.failure()));
|
||||
} catch (RuntimeException exception) {
|
||||
logUnexpected(providerCode, exception);
|
||||
return redirect(failureTarget(
|
||||
CasLoginFailure.INTERNAL_ERROR));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/callback")
|
||||
@Operation(summary = "Complete a CAS browser login")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "302",
|
||||
description = "Redirect to the authenticated target or login failure page")
|
||||
})
|
||||
public ResponseEntity<Void> callback(
|
||||
@PathVariable String providerCode,
|
||||
@RequestParam(required = false) String ticket,
|
||||
@RequestParam(required = false) String state,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
return redirect(URI.create(loginAppService.complete(
|
||||
providerCode,
|
||||
ticket,
|
||||
state,
|
||||
request)));
|
||||
} catch (CasLoginFlowException exception) {
|
||||
return redirect(failureTarget(exception.failure()));
|
||||
} catch (RuntimeException exception) {
|
||||
logUnexpected(providerCode, exception);
|
||||
return redirect(failureTarget(
|
||||
CasLoginFailure.INTERNAL_ERROR));
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Void> redirect(URI target) {
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.location(target)
|
||||
.build();
|
||||
}
|
||||
|
||||
private URI failureTarget(CasLoginFailure failure) {
|
||||
return switch (failure) {
|
||||
case ACCOUNT_PENDING -> URI.create("/pending-approval");
|
||||
case ACCESS_DENIED -> URI.create("/access-denied");
|
||||
case LINK_REQUIRED ->
|
||||
URI.create("/login?reason=linkRequired");
|
||||
case INVALID_STATE ->
|
||||
URI.create("/login?reason=casInvalidState");
|
||||
case REPLAY_DETECTED ->
|
||||
URI.create("/login?reason=casReplayDetected");
|
||||
case TICKET_MISSING ->
|
||||
URI.create("/login?reason=casTicketMissing");
|
||||
case VALIDATION_FAILED ->
|
||||
URI.create("/login?reason=casValidationFailed");
|
||||
case PROVIDER_UNAVAILABLE ->
|
||||
URI.create("/login?reason=casUnavailable");
|
||||
case INTERNAL_ERROR ->
|
||||
URI.create("/login?reason=internalError");
|
||||
};
|
||||
}
|
||||
|
||||
private void logUnexpected(
|
||||
String providerCode,
|
||||
RuntimeException exception) {
|
||||
log.error(
|
||||
"CAS browser flow failed unexpectedly [requestId={}, provider={}, failure={}]",
|
||||
MDC.get("requestId"),
|
||||
providerCode,
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.filter;
|
||||
|
||||
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
|
@ -35,6 +36,15 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
private static final Set<String> SKIP_SUFFIXES = Set.of(
|
||||
"/sse"
|
||||
);
|
||||
private static final Set<String> SENSITIVE_BODY_PREFIXES = Set.of(
|
||||
"/api/v1/auth/"
|
||||
);
|
||||
private final SensitiveLogSanitizer sensitiveLogSanitizer;
|
||||
|
||||
public RequestLoggingFilter(
|
||||
SensitiveLogSanitizer sensitiveLogSanitizer) {
|
||||
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
|
|
@ -66,15 +76,14 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
}
|
||||
|
||||
private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) {
|
||||
String requestUri = request.getRequestURI();
|
||||
String queryString = request.getQueryString();
|
||||
String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri;
|
||||
String requestTarget =
|
||||
sensitiveLogSanitizer.sanitizeRequestTarget(request);
|
||||
|
||||
String contentType = request.getContentType();
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(request.getMethod()).append(" ").append(fullUrl);
|
||||
sb.append(request.getMethod()).append(" ").append(requestTarget);
|
||||
sb.append(" | ").append(response.getStatus());
|
||||
sb.append(" | ").append(duration).append("ms");
|
||||
sb.append(" | ").append(request.getRemoteAddr());
|
||||
|
|
@ -85,7 +94,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
sb.append(" | UA: ").append(truncate(userAgent, 80));
|
||||
}
|
||||
|
||||
String requestBody = getRequestBody(request);
|
||||
String requestBody = shouldLogBody(request.getRequestURI())
|
||||
? getRequestBody(request)
|
||||
: null;
|
||||
if (requestBody != null && !requestBody.isBlank()) {
|
||||
sb.append(" | Body: ").append(requestBody);
|
||||
}
|
||||
|
|
@ -107,6 +118,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
return false;
|
||||
}
|
||||
|
||||
private boolean shouldLogBody(String uri) {
|
||||
return SENSITIVE_BODY_PREFIXES.stream()
|
||||
.noneMatch(uri::startsWith);
|
||||
}
|
||||
|
||||
private boolean isNotificationSse(String uri) {
|
||||
return uri != null && uri.endsWith("/notifications/sse");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.security;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
|
@ -16,7 +18,8 @@ public class SensitiveLogSanitizer {
|
|||
|
||||
private static final Set<String> SENSITIVE_KEYS = Set.of(
|
||||
"password", "passwd", "pwd", "token", "authorization", "cookie",
|
||||
"secret", "api_key", "apikey", "access_key", "refresh_token", "code");
|
||||
"secret", "api_key", "apikey", "access_key", "refresh_token",
|
||||
"code", "ticket", "state");
|
||||
|
||||
public String sanitizeRequestTarget(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
|
|
@ -39,7 +42,16 @@ public class SensitiveLogSanitizer {
|
|||
return queryPart;
|
||||
}
|
||||
String key = queryPart.substring(0, idx);
|
||||
String normalizedKey = key.trim().toLowerCase(Locale.ROOT);
|
||||
String normalizedKey;
|
||||
try {
|
||||
normalizedKey = URLDecoder.decode(
|
||||
key,
|
||||
StandardCharsets.UTF_8)
|
||||
.trim()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return key + "=[REDACTED]";
|
||||
}
|
||||
if (SENSITIVE_KEYS.contains(normalizedKey)) {
|
||||
return key + "=[REDACTED]";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ public class AuthMethodCatalog {
|
|||
|
||||
private boolean isMethodEnabled(IdentityProviderLoginMethod method) {
|
||||
return switch (method.methodType()) {
|
||||
case OAUTH_REDIRECT -> true;
|
||||
case OAUTH_REDIRECT, CAS_REDIRECT -> true;
|
||||
case DIRECT_PASSWORD -> directAuthProperties.isEnabled();
|
||||
case SESSION_BOOTSTRAP -> sessionBootstrapProperties.isEnabled();
|
||||
};
|
||||
|
|
@ -101,6 +101,13 @@ public class AuthMethodCatalog {
|
|||
method.displayName(),
|
||||
buildAuthorizationUrl(providerCode, returnTo)
|
||||
);
|
||||
case CAS_REDIRECT -> new AuthMethodResponse(
|
||||
"cas-" + providerCode,
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT.name(),
|
||||
providerCode,
|
||||
method.displayName(),
|
||||
buildCasAuthorizationUrl(providerCode, returnTo)
|
||||
);
|
||||
case DIRECT_PASSWORD -> new AuthMethodResponse(
|
||||
"direct-" + providerCode,
|
||||
IdentityProviderLoginMethodType.DIRECT_PASSWORD.name(),
|
||||
|
|
@ -125,4 +132,18 @@ public class AuthMethodCatalog {
|
|||
}
|
||||
return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String buildCasAuthorizationUrl(
|
||||
String providerCode,
|
||||
String returnTo) {
|
||||
String baseUrl = "/api/v1/auth/cas/"
|
||||
+ providerCode
|
||||
+ "/login";
|
||||
if (returnTo == null) {
|
||||
return baseUrl;
|
||||
}
|
||||
return baseUrl + "?returnTo=" + URLEncoder.encode(
|
||||
returnTo,
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,447 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.cas.CasAuthenticationExchange;
|
||||
import com.iflytek.skillhub.auth.cas.CasBrowserClient;
|
||||
import com.iflytek.skillhub.auth.cas.CasLoginInitiation;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.net.URI;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.regex.Pattern;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Orchestrates the CAS browser flow while keeping the remote ticket exchange
|
||||
* outside the unified identity transaction.
|
||||
*/
|
||||
@Service
|
||||
public class CasLoginAppService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
CasLoginAppService.class);
|
||||
private static final SecureRandom SECURE_RANDOM =
|
||||
new SecureRandom();
|
||||
private static final Pattern PROVIDER_CODE_PATTERN =
|
||||
Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}");
|
||||
|
||||
private final IdentityProviderRegistry providerRegistry;
|
||||
private final CasBrowserClient protocolClient;
|
||||
private final ProviderLoginAppService providerLoginAppService;
|
||||
private final ExternalIdentityLinkService externalIdentityLinkService;
|
||||
private final IdentityLinkSessionManager identityLinkSessionManager;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
private final CasLoginStateStore stateStore;
|
||||
private final AuditLogService auditLogService;
|
||||
private final Supplier<String> stateSupplier;
|
||||
|
||||
@Autowired
|
||||
public CasLoginAppService(
|
||||
IdentityProviderRegistry providerRegistry,
|
||||
CasBrowserClient protocolClient,
|
||||
ProviderLoginAppService providerLoginAppService,
|
||||
ExternalIdentityLinkService externalIdentityLinkService,
|
||||
IdentityLinkSessionManager identityLinkSessionManager,
|
||||
PlatformSessionService platformSessionService,
|
||||
CasLoginStateStore stateStore,
|
||||
AuditLogService auditLogService) {
|
||||
this(
|
||||
providerRegistry,
|
||||
protocolClient,
|
||||
providerLoginAppService,
|
||||
externalIdentityLinkService,
|
||||
identityLinkSessionManager,
|
||||
platformSessionService,
|
||||
stateStore,
|
||||
auditLogService,
|
||||
CasLoginAppService::newState);
|
||||
}
|
||||
|
||||
CasLoginAppService(
|
||||
IdentityProviderRegistry providerRegistry,
|
||||
CasBrowserClient protocolClient,
|
||||
ProviderLoginAppService providerLoginAppService,
|
||||
ExternalIdentityLinkService externalIdentityLinkService,
|
||||
IdentityLinkSessionManager identityLinkSessionManager,
|
||||
PlatformSessionService platformSessionService,
|
||||
CasLoginStateStore stateStore,
|
||||
AuditLogService auditLogService,
|
||||
Supplier<String> stateSupplier) {
|
||||
this.providerRegistry = providerRegistry;
|
||||
this.protocolClient = protocolClient;
|
||||
this.providerLoginAppService = providerLoginAppService;
|
||||
this.externalIdentityLinkService = externalIdentityLinkService;
|
||||
this.identityLinkSessionManager = identityLinkSessionManager;
|
||||
this.platformSessionService = platformSessionService;
|
||||
this.stateStore = stateStore;
|
||||
this.auditLogService = auditLogService;
|
||||
this.stateSupplier = stateSupplier;
|
||||
}
|
||||
|
||||
public URI begin(
|
||||
String providerCode,
|
||||
String returnTo,
|
||||
HttpServletRequest request) {
|
||||
requireRoute(providerCode);
|
||||
HttpSession session = request.getSession(true);
|
||||
String state = stateSupplier.get();
|
||||
CasLoginInitiation initiation;
|
||||
try {
|
||||
initiation = protocolClient.begin(
|
||||
providerCode,
|
||||
state);
|
||||
} catch (ProviderAuthenticationException exception) {
|
||||
throw failure(CasLoginFailure.PROVIDER_UNAVAILABLE);
|
||||
}
|
||||
|
||||
try {
|
||||
stateStore.save(
|
||||
session.getId(),
|
||||
state,
|
||||
providerCode,
|
||||
initiation.serviceUrl(),
|
||||
OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo),
|
||||
initiation.stateTtl());
|
||||
identityLinkSessionManager.activateBrowserFlow(
|
||||
session,
|
||||
providerCode,
|
||||
state);
|
||||
} catch (CasLoginStateStore.CasLoginStateStoreException exception) {
|
||||
throw failure(CasLoginFailure.INTERNAL_ERROR);
|
||||
}
|
||||
return initiation.loginUri();
|
||||
}
|
||||
|
||||
public String complete(
|
||||
String providerCode,
|
||||
String ticket,
|
||||
String state,
|
||||
HttpServletRequest request) {
|
||||
CasLoginStateStore.CasLoginState loginState =
|
||||
consumeState(providerCode, state, request);
|
||||
IdentityLoginContext context = context(request);
|
||||
Optional<IdentityLinkBrowserFlow> identityLinkFlow;
|
||||
try {
|
||||
identityLinkFlow =
|
||||
identityLinkSessionManager.consumeBrowserFlow(
|
||||
request,
|
||||
providerCode,
|
||||
context);
|
||||
} catch (IdentityLinkException exception) {
|
||||
throw failure(CasLoginFailure.INVALID_STATE);
|
||||
}
|
||||
if (ticket == null || ticket.isBlank()) {
|
||||
if (identityLinkFlow.isPresent()) {
|
||||
return identityLinkFailureTarget(
|
||||
identityLinkFlow.orElseThrow().intentId(),
|
||||
IdentityLinkFailureCode
|
||||
.PROVIDER_AUTHENTICATION_FAILED);
|
||||
}
|
||||
throw failure(CasLoginFailure.TICKET_MISSING);
|
||||
}
|
||||
|
||||
IdentityProviderRegistry.BrowserRoute<CasAuthenticationExchange>
|
||||
route = requireRoute(providerCode);
|
||||
try {
|
||||
CasAuthenticationExchange exchange =
|
||||
protocolClient.validate(
|
||||
providerCode,
|
||||
ticket,
|
||||
loginState.serviceUrl());
|
||||
var result = route.adapter().authenticate(exchange);
|
||||
if (identityLinkFlow.isPresent()) {
|
||||
completeIdentityLink(
|
||||
identityLinkFlow.orElseThrow(),
|
||||
route,
|
||||
result,
|
||||
request);
|
||||
} else {
|
||||
PlatformPrincipal principal =
|
||||
providerLoginAppService.authenticate(
|
||||
route.provider(),
|
||||
result,
|
||||
request);
|
||||
platformSessionService.establishSession(
|
||||
principal,
|
||||
request);
|
||||
}
|
||||
} catch (ProviderAuthenticationException exception) {
|
||||
if (exception.getReasonCode()
|
||||
== ProviderAuthenticationFailureCode
|
||||
.REPLAY_DETECTED) {
|
||||
recordReplayAudit(
|
||||
providerCode,
|
||||
request,
|
||||
"ticket");
|
||||
}
|
||||
if (identityLinkFlow.isPresent()) {
|
||||
return identityLinkFailureTarget(
|
||||
identityLinkFlow.orElseThrow().intentId(),
|
||||
ProviderAuthenticationFailureMapper
|
||||
.mapIdentityLink(exception)
|
||||
.getReasonCode());
|
||||
}
|
||||
throw mapProviderFailure(exception);
|
||||
} catch (IdentityLinkException exception) {
|
||||
if (identityLinkFlow.isPresent()) {
|
||||
return identityLinkFailureTarget(
|
||||
identityLinkFlow.orElseThrow().intentId(),
|
||||
exception.getReasonCode());
|
||||
}
|
||||
throw failure(CasLoginFailure.INTERNAL_ERROR);
|
||||
} catch (IdentityCoreException exception) {
|
||||
if (identityLinkFlow.isPresent()) {
|
||||
return identityLinkFailureTarget(
|
||||
identityLinkFlow.orElseThrow().intentId(),
|
||||
mapIdentityCoreFailure(exception));
|
||||
}
|
||||
throw failure(CasLoginFailure.INTERNAL_ERROR);
|
||||
} catch (AuthFlowException exception) {
|
||||
throw mapIdentityFailure(exception);
|
||||
}
|
||||
|
||||
String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(
|
||||
loginState.returnTo());
|
||||
return returnTo == null
|
||||
? OAuthLoginRedirectSupport.DEFAULT_TARGET_URL
|
||||
: returnTo;
|
||||
}
|
||||
|
||||
private IdentityProviderRegistry.BrowserRoute
|
||||
<CasAuthenticationExchange> requireRoute(
|
||||
String providerCode) {
|
||||
try {
|
||||
return providerRegistry.requireBrowserRoute(
|
||||
providerCode,
|
||||
CasAuthenticationExchange.class);
|
||||
} catch (IdentityCoreException exception) {
|
||||
throw failure(CasLoginFailure.PROVIDER_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private CasLoginStateStore.CasLoginState consumeState(
|
||||
String providerCode,
|
||||
String presentedState,
|
||||
HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null || presentedState == null) {
|
||||
throw failure(CasLoginFailure.INVALID_STATE);
|
||||
}
|
||||
CasLoginStateStore.CasLoginState stored;
|
||||
try {
|
||||
CasLoginStateStore.ConsumeResult result =
|
||||
stateStore.consume(
|
||||
session.getId(),
|
||||
presentedState);
|
||||
if (result.status()
|
||||
== CasLoginStateStore.ConsumeStatus.REPLAYED) {
|
||||
recordReplayAudit(
|
||||
providerCode,
|
||||
request,
|
||||
"state");
|
||||
throw failure(
|
||||
CasLoginFailure.REPLAY_DETECTED);
|
||||
}
|
||||
if (result.status()
|
||||
!= CasLoginStateStore.ConsumeStatus.CONSUMED) {
|
||||
throw failure(
|
||||
CasLoginFailure.INVALID_STATE);
|
||||
}
|
||||
stored = result.state();
|
||||
} catch (CasLoginStateStore.CasLoginStateStoreException exception) {
|
||||
throw failure(CasLoginFailure.INTERNAL_ERROR);
|
||||
}
|
||||
if (!stored.providerCode().equals(providerCode)) {
|
||||
throw failure(CasLoginFailure.INVALID_STATE);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private void completeIdentityLink(
|
||||
IdentityLinkBrowserFlow flow,
|
||||
IdentityProviderRegistry.BrowserRoute
|
||||
<CasAuthenticationExchange> route,
|
||||
ProviderAuthenticationResult result,
|
||||
HttpServletRequest request) {
|
||||
IdentityLinkOutcome outcome;
|
||||
if (flow.phase()
|
||||
== IdentityLinkBrowserPhase.REAUTHENTICATE) {
|
||||
outcome = externalIdentityLinkService.reauthenticate(
|
||||
flow.actor(),
|
||||
flow.intentId(),
|
||||
route.provider(),
|
||||
result);
|
||||
} else {
|
||||
outcome = externalIdentityLinkService.link(
|
||||
flow.actor(),
|
||||
flow.intentId(),
|
||||
route.provider(),
|
||||
result);
|
||||
}
|
||||
if (outcome instanceof IdentityLinkOutcome.Reauthenticated) {
|
||||
return;
|
||||
}
|
||||
if (outcome instanceof IdentityLinkOutcome.Linked) {
|
||||
identityLinkSessionManager.remove(
|
||||
request.getSession(false),
|
||||
flow.intentId());
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Unsupported CAS identity link outcome");
|
||||
}
|
||||
|
||||
private IdentityLinkFailureCode mapIdentityCoreFailure(
|
||||
IdentityCoreException exception) {
|
||||
IdentityFailureCode code = exception.getReasonCode();
|
||||
return switch (code) {
|
||||
case PROVIDER_DISABLED,
|
||||
PROVIDER_AUTHORITY_MISMATCH ->
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE;
|
||||
case INVALID_IDENTITY_ASSERTION,
|
||||
IDENTITY_SUBJECT_MISSING,
|
||||
IDENTITY_IDENTIFIER_CONFLICT ->
|
||||
IdentityLinkFailureCode
|
||||
.PROVIDER_AUTHENTICATION_FAILED;
|
||||
case ACCESS_DENIED,
|
||||
ACCOUNT_PENDING,
|
||||
ACCOUNT_DISABLED,
|
||||
ACCOUNT_MERGED,
|
||||
SYSTEM_ACCOUNT_FORBIDDEN ->
|
||||
IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE;
|
||||
};
|
||||
}
|
||||
|
||||
private String identityLinkFailureTarget(
|
||||
UUID intentId,
|
||||
IdentityLinkFailureCode reasonCode) {
|
||||
return "/settings/security?identityLink=failed"
|
||||
+ "&intentId="
|
||||
+ intentId
|
||||
+ "&reasonCode="
|
||||
+ reasonCode.name();
|
||||
}
|
||||
|
||||
private IdentityLoginContext context(
|
||||
HttpServletRequest request) {
|
||||
return new IdentityLoginContext(
|
||||
bounded(MDC.get("requestId"), 64),
|
||||
bounded(request.getRemoteAddr(), 64),
|
||||
bounded(request.getHeader("User-Agent"), 512));
|
||||
}
|
||||
|
||||
private void recordReplayAudit(
|
||||
String providerCode,
|
||||
HttpServletRequest request,
|
||||
String artifact) {
|
||||
String safeProvider = providerCode != null
|
||||
&& PROVIDER_CODE_PATTERN.matcher(providerCode).matches()
|
||||
? providerCode
|
||||
: "unresolved";
|
||||
try {
|
||||
auditLogService.record(
|
||||
null,
|
||||
"IDENTITY_REPLAY_DETECTED",
|
||||
"IDENTITY_PROVIDER",
|
||||
null,
|
||||
bounded(MDC.get("requestId"), 64),
|
||||
bounded(request.getRemoteAddr(), 64),
|
||||
bounded(request.getHeader("User-Agent"), 512),
|
||||
"{\"providerCode\":\""
|
||||
+ safeProvider
|
||||
+ "\",\"protocol\":\"cas\","
|
||||
+ "\"reason\":\"REPLAY_DETECTED\","
|
||||
+ "\"artifact\":\""
|
||||
+ artifact
|
||||
+ "\"}");
|
||||
} catch (RuntimeException auditFailure) {
|
||||
log.error(
|
||||
"CAS replay audit failed [provider={}, failure={}]",
|
||||
safeProvider,
|
||||
auditFailure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private String bounded(
|
||||
String value,
|
||||
int maximumLength) {
|
||||
return value == null || value.length() > maximumLength
|
||||
? null
|
||||
: value;
|
||||
}
|
||||
|
||||
private CasLoginFlowException mapProviderFailure(
|
||||
ProviderAuthenticationException exception) {
|
||||
ProviderAuthenticationFailureCode code =
|
||||
exception.getReasonCode();
|
||||
return switch (code) {
|
||||
case UPSTREAM_INVALID_CREDENTIALS,
|
||||
REPLAY_DETECTED ->
|
||||
failure(CasLoginFailure.VALIDATION_FAILED);
|
||||
case UPSTREAM_ACCESS_DENIED ->
|
||||
failure(CasLoginFailure.ACCESS_DENIED);
|
||||
case UPSTREAM_UNAVAILABLE,
|
||||
UPSTREAM_MISCONFIGURED,
|
||||
TLS_VALIDATION_FAILED,
|
||||
UPSTREAM_INVALID_RESPONSE ->
|
||||
failure(CasLoginFailure.PROVIDER_UNAVAILABLE);
|
||||
};
|
||||
}
|
||||
|
||||
private CasLoginFlowException mapIdentityFailure(
|
||||
AuthFlowException exception) {
|
||||
return switch (exception.getMessageCode()) {
|
||||
case "error.auth.external.accountPending" ->
|
||||
failure(CasLoginFailure.ACCOUNT_PENDING);
|
||||
case "error.auth.external.accessDenied" ->
|
||||
failure(CasLoginFailure.ACCESS_DENIED);
|
||||
case "error.auth.external.linkRequired" ->
|
||||
failure(CasLoginFailure.LINK_REQUIRED);
|
||||
case "error.auth.external.providerUnavailable" ->
|
||||
failure(CasLoginFailure.PROVIDER_UNAVAILABLE);
|
||||
case "error.auth.external.invalidAssertion" ->
|
||||
failure(CasLoginFailure.VALIDATION_FAILED);
|
||||
default -> failure(CasLoginFailure.INTERNAL_ERROR);
|
||||
};
|
||||
}
|
||||
|
||||
private CasLoginFlowException failure(
|
||||
CasLoginFailure failure) {
|
||||
return new CasLoginFlowException(failure);
|
||||
}
|
||||
|
||||
private static String newState() {
|
||||
byte[] bytes = new byte[32];
|
||||
SECURE_RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(bytes);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
/**
|
||||
* Stable, credential-free browser failure reported by the CAS login flow.
|
||||
*/
|
||||
public enum CasLoginFailure {
|
||||
INVALID_STATE,
|
||||
REPLAY_DETECTED,
|
||||
TICKET_MISSING,
|
||||
VALIDATION_FAILED,
|
||||
PROVIDER_UNAVAILABLE,
|
||||
ACCESS_DENIED,
|
||||
ACCOUNT_PENDING,
|
||||
LINK_REQUIRED,
|
||||
INTERNAL_ERROR
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Browser-flow control exception that never carries a ticket, state, upstream
|
||||
* payload, or user identifier.
|
||||
*/
|
||||
public final class CasLoginFlowException extends RuntimeException {
|
||||
|
||||
private final CasLoginFailure failure;
|
||||
|
||||
public CasLoginFlowException(CasLoginFailure failure) {
|
||||
super(Objects.requireNonNull(failure, "failure").name());
|
||||
this.failure = failure;
|
||||
}
|
||||
|
||||
public CasLoginFailure failure() {
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Stores CAS browser state in Redis and consumes it atomically across
|
||||
* application instances.
|
||||
*/
|
||||
@Component
|
||||
public class CasLoginStateStore {
|
||||
|
||||
private static final String KEY_PREFIX =
|
||||
"skillhub:auth:cas:state:";
|
||||
private static final String PENDING_PREFIX = "P:";
|
||||
private static final String REPLAY_MARKER = "R";
|
||||
private static final String INVALID_MARKER = "E";
|
||||
private static final Pattern STATE_PATTERN =
|
||||
Pattern.compile("[A-Za-z0-9_-]{32,128}");
|
||||
private static final DefaultRedisScript<String> CONSUME_SCRIPT =
|
||||
new DefaultRedisScript<>(
|
||||
"""
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if not value then
|
||||
return nil
|
||||
end
|
||||
if value == 'R' then
|
||||
return 'R'
|
||||
end
|
||||
if string.sub(value, 1, 2) ~= 'P:' then
|
||||
return 'E'
|
||||
end
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
if ttl <= 0 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return nil
|
||||
end
|
||||
redis.call('SET', KEYS[1], 'R', 'PX', ttl)
|
||||
return value
|
||||
""",
|
||||
String.class);
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public CasLoginStateStore(
|
||||
StringRedisTemplate redisTemplate,
|
||||
ObjectMapper objectMapper) {
|
||||
this(redisTemplate, objectMapper, Clock.systemUTC());
|
||||
}
|
||||
|
||||
CasLoginStateStore(
|
||||
StringRedisTemplate redisTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
void save(
|
||||
String sessionId,
|
||||
String state,
|
||||
String providerCode,
|
||||
String serviceUrl,
|
||||
String returnTo,
|
||||
Duration ttl) {
|
||||
requireSessionAndState(sessionId, state);
|
||||
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
CasLoginState loginState = new CasLoginState(
|
||||
providerCode,
|
||||
serviceUrl,
|
||||
returnTo,
|
||||
clock.instant().plus(ttl));
|
||||
try {
|
||||
redisTemplate.opsForValue().set(
|
||||
key(sessionId, state),
|
||||
PENDING_PREFIX
|
||||
+ objectMapper.writeValueAsString(loginState),
|
||||
ttl);
|
||||
} catch (RuntimeException
|
||||
| JsonProcessingException exception) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
}
|
||||
|
||||
ConsumeResult consume(
|
||||
String sessionId,
|
||||
String state) {
|
||||
if (!validSessionAndState(sessionId, state)) {
|
||||
return ConsumeResult.notFound();
|
||||
}
|
||||
String serialized;
|
||||
try {
|
||||
serialized = redisTemplate.execute(
|
||||
CONSUME_SCRIPT,
|
||||
List.of(key(sessionId, state)));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
if (serialized == null) {
|
||||
return ConsumeResult.notFound();
|
||||
}
|
||||
if (REPLAY_MARKER.equals(serialized)) {
|
||||
return ConsumeResult.replayed();
|
||||
}
|
||||
if (INVALID_MARKER.equals(serialized)
|
||||
|| !serialized.startsWith(PENDING_PREFIX)) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
try {
|
||||
CasLoginState loginState = objectMapper.readValue(
|
||||
serialized.substring(PENDING_PREFIX.length()),
|
||||
CasLoginState.class);
|
||||
if (!loginState.expiresAt().isAfter(clock.instant())) {
|
||||
return ConsumeResult.notFound();
|
||||
}
|
||||
return ConsumeResult.consumed(loginState);
|
||||
} catch (RuntimeException
|
||||
| JsonProcessingException exception) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSessionAndState(
|
||||
String sessionId,
|
||||
String state) {
|
||||
if (!validSessionAndState(sessionId, state)) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validSessionAndState(
|
||||
String sessionId,
|
||||
String state) {
|
||||
return sessionId != null
|
||||
&& !sessionId.isBlank()
|
||||
&& state != null
|
||||
&& STATE_PATTERN.matcher(state).matches();
|
||||
}
|
||||
|
||||
private String key(
|
||||
String sessionId,
|
||||
String state) {
|
||||
try {
|
||||
MessageDigest digest =
|
||||
MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(
|
||||
(sessionId + "\n" + state)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
return KEY_PREFIX + HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new CasLoginStateStoreException();
|
||||
}
|
||||
}
|
||||
|
||||
record CasLoginState(
|
||||
String providerCode,
|
||||
String serviceUrl,
|
||||
String returnTo,
|
||||
Instant expiresAt
|
||||
) {
|
||||
CasLoginState {
|
||||
Objects.requireNonNull(
|
||||
providerCode,
|
||||
"providerCode");
|
||||
Objects.requireNonNull(
|
||||
serviceUrl,
|
||||
"serviceUrl");
|
||||
Objects.requireNonNull(
|
||||
expiresAt,
|
||||
"expiresAt");
|
||||
if (providerCode.isBlank()
|
||||
|| serviceUrl.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"CAS state fields must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ConsumeStatus {
|
||||
CONSUMED,
|
||||
NOT_FOUND,
|
||||
REPLAYED
|
||||
}
|
||||
|
||||
record ConsumeResult(
|
||||
ConsumeStatus status,
|
||||
CasLoginState state
|
||||
) {
|
||||
private static ConsumeResult consumed(
|
||||
CasLoginState state) {
|
||||
return new ConsumeResult(
|
||||
ConsumeStatus.CONSUMED,
|
||||
Objects.requireNonNull(state, "state"));
|
||||
}
|
||||
|
||||
private static ConsumeResult notFound() {
|
||||
return new ConsumeResult(
|
||||
ConsumeStatus.NOT_FOUND,
|
||||
null);
|
||||
}
|
||||
|
||||
private static ConsumeResult replayed() {
|
||||
return new ConsumeResult(
|
||||
ConsumeStatus.REPLAYED,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
static final class CasLoginStateStoreException
|
||||
extends RuntimeException {
|
||||
|
||||
private CasLoginStateStoreException() {
|
||||
super("CAS_LOGIN_STATE_STORE_FAILURE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import com.iflytek.skillhub.dto.IdentityLinkProviderResponse;
|
|||
import jakarta.servlet.http.HttpSession;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Application orchestration for explicit link/unlink workflows. Protocol I/O
|
||||
|
|
@ -182,12 +183,14 @@ public class IdentityLinkAppService {
|
|||
session,
|
||||
intentId,
|
||||
context);
|
||||
IdentityProviderLoginMethodType browserMethod =
|
||||
requireBrowserMethod(providerCode);
|
||||
IdentityLinkIntent intent =
|
||||
intentService.prepareExternalReauthentication(
|
||||
actor,
|
||||
intentId,
|
||||
providerCode,
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT);
|
||||
actor,
|
||||
intentId,
|
||||
providerCode,
|
||||
browserMethod);
|
||||
sessionManager.prepareBrowserFlow(
|
||||
session,
|
||||
intentId,
|
||||
|
|
@ -196,6 +199,7 @@ public class IdentityLinkAppService {
|
|||
context);
|
||||
return browserAuthorizationUrl(
|
||||
providerCode,
|
||||
browserMethod,
|
||||
"/settings/security?identityLink=reauthenticated"
|
||||
+ "&intentId="
|
||||
+ intentId);
|
||||
|
|
@ -209,10 +213,15 @@ public class IdentityLinkAppService {
|
|||
session,
|
||||
intentId,
|
||||
context);
|
||||
IdentityLinkIntent current = intentService.getIntent(
|
||||
actor,
|
||||
intentId);
|
||||
IdentityProviderLoginMethodType browserMethod =
|
||||
requireBrowserMethod(current.providerCode());
|
||||
IdentityLinkIntent intent = intentService.prepareExternalLink(
|
||||
actor,
|
||||
intentId,
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT);
|
||||
browserMethod);
|
||||
sessionManager.prepareBrowserFlow(
|
||||
session,
|
||||
intentId,
|
||||
|
|
@ -221,6 +230,7 @@ public class IdentityLinkAppService {
|
|||
context);
|
||||
return browserAuthorizationUrl(
|
||||
intent.providerCode(),
|
||||
browserMethod,
|
||||
"/settings/security?identityLink=linked"
|
||||
+ "&intentId="
|
||||
+ intentId);
|
||||
|
|
@ -326,13 +336,52 @@ public class IdentityLinkAppService {
|
|||
|
||||
private String browserAuthorizationUrl(
|
||||
String providerCode,
|
||||
IdentityProviderLoginMethodType methodType,
|
||||
String returnTo) {
|
||||
return "/oauth2/authorization/"
|
||||
+ providerCode
|
||||
+ "?returnTo="
|
||||
+ java.net.URLEncoder.encode(
|
||||
returnTo,
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (methodType
|
||||
== IdentityProviderLoginMethodType.OAUTH_REDIRECT) {
|
||||
return "/oauth2/authorization/"
|
||||
+ providerCode
|
||||
+ "?returnTo="
|
||||
+ java.net.URLEncoder.encode(
|
||||
returnTo,
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
if (methodType
|
||||
== IdentityProviderLoginMethodType.CAS_REDIRECT) {
|
||||
return UriComponentsBuilder
|
||||
.fromPath(
|
||||
"/api/v1/auth/cas/{providerCode}/login")
|
||||
.queryParam("returnTo", returnTo)
|
||||
.buildAndExpand(providerCode)
|
||||
.encode()
|
||||
.toUriString();
|
||||
}
|
||||
throw new IdentityLinkException(
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private IdentityProviderLoginMethodType requireBrowserMethod(
|
||||
String providerCode) {
|
||||
boolean casAvailable = false;
|
||||
for (var method : providerRegistry.listReadyLoginMethods()) {
|
||||
if (!method.providerCode().equals(providerCode)) {
|
||||
continue;
|
||||
}
|
||||
if (method.methodType()
|
||||
== IdentityProviderLoginMethodType.OAUTH_REDIRECT) {
|
||||
return IdentityProviderLoginMethodType.OAUTH_REDIRECT;
|
||||
}
|
||||
if (method.methodType()
|
||||
== IdentityProviderLoginMethodType.CAS_REDIRECT) {
|
||||
casAvailable = true;
|
||||
}
|
||||
}
|
||||
if (casAvailable) {
|
||||
return IdentityProviderLoginMethodType.CAS_REDIRECT;
|
||||
}
|
||||
throw new IdentityLinkException(
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private IdentityLinkIntentResponse toResponse(
|
||||
|
|
|
|||
|
|
@ -118,6 +118,25 @@ skillhub:
|
|||
enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false}
|
||||
session-bootstrap:
|
||||
enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false}
|
||||
cas:
|
||||
enabled: ${SKILLHUB_AUTH_CAS_ENABLED:false}
|
||||
provider-code: ${SKILLHUB_AUTH_CAS_PROVIDER_CODE:cas}
|
||||
display-name: ${SKILLHUB_AUTH_CAS_DISPLAY_NAME:CAS}
|
||||
authority: ${SKILLHUB_AUTH_CAS_AUTHORITY:}
|
||||
server-url: ${SKILLHUB_AUTH_CAS_SERVER_URL:}
|
||||
service-url: ${SKILLHUB_AUTH_CAS_SERVICE_URL:}
|
||||
protocol-version: ${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:3.0}
|
||||
subject-type: ${SKILLHUB_AUTH_CAS_SUBJECT_TYPE:cas_principal}
|
||||
allow-insecure-for-testing: ${SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING:false}
|
||||
connect-timeout: ${SKILLHUB_AUTH_CAS_CONNECT_TIMEOUT:PT5S}
|
||||
read-timeout: ${SKILLHUB_AUTH_CAS_READ_TIMEOUT:PT10S}
|
||||
state-ttl: ${SKILLHUB_AUTH_CAS_STATE_TTL:PT5M}
|
||||
max-response-bytes: ${SKILLHUB_AUTH_CAS_MAX_RESPONSE_BYTES:1048576}
|
||||
attributes:
|
||||
subject: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_SUBJECT:}
|
||||
display-name: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME:}
|
||||
email: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL:}
|
||||
avatar-url: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL:}
|
||||
password-reset:
|
||||
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
|
||||
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.service.CasLoginAppService;
|
||||
import com.iflytek.skillhub.service.CasLoginFailure;
|
||||
import com.iflytek.skillhub.service.CasLoginFlowException;
|
||||
import java.net.URI;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
class CasLoginControllerTest {
|
||||
|
||||
private final CasLoginAppService loginAppService =
|
||||
mock(CasLoginAppService.class);
|
||||
private final CasLoginController controller =
|
||||
new CasLoginController(loginAppService);
|
||||
|
||||
@Test
|
||||
void loginRedirectsOnlyToApplicationServiceTarget() {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest();
|
||||
URI target = URI.create(
|
||||
"https://cas.example/login?service=opaque");
|
||||
when(loginAppService.begin(
|
||||
"cas-main",
|
||||
"/dashboard",
|
||||
request)).thenReturn(target);
|
||||
|
||||
var response = controller.login(
|
||||
"cas-main",
|
||||
"/dashboard",
|
||||
request);
|
||||
|
||||
assertThat(response.getStatusCode())
|
||||
.isEqualTo(HttpStatus.FOUND);
|
||||
assertThat(response.getHeaders().getLocation())
|
||||
.isEqualTo(target);
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackMapsStableFailureWithoutReflectingTicketOrState() {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest();
|
||||
when(loginAppService.complete(
|
||||
"cas-main",
|
||||
"ST-secret",
|
||||
"state-secret",
|
||||
request)).thenThrow(
|
||||
new CasLoginFlowException(
|
||||
CasLoginFailure.VALIDATION_FAILED));
|
||||
|
||||
var response = controller.callback(
|
||||
"cas-main",
|
||||
"ST-secret",
|
||||
"state-secret",
|
||||
request);
|
||||
|
||||
assertThat(response.getStatusCode())
|
||||
.isEqualTo(HttpStatus.FOUND);
|
||||
assertThat(response.getHeaders().getLocation())
|
||||
.hasToString(
|
||||
"/login?reason=casValidationFailed");
|
||||
assertThat(response.getHeaders().getLocation().toString())
|
||||
.doesNotContain("ST-secret")
|
||||
.doesNotContain("state-secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackMapsReplayToDedicatedCredentialFreeReason() {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest();
|
||||
when(loginAppService.complete(
|
||||
"cas-main",
|
||||
"ST-replayed",
|
||||
"state-replayed",
|
||||
request)).thenThrow(
|
||||
new CasLoginFlowException(
|
||||
CasLoginFailure.REPLAY_DETECTED));
|
||||
|
||||
var response = controller.callback(
|
||||
"cas-main",
|
||||
"ST-replayed",
|
||||
"state-replayed",
|
||||
request);
|
||||
|
||||
assertThat(response.getHeaders().getLocation())
|
||||
.hasToString(
|
||||
"/login?reason=casReplayDetected");
|
||||
assertThat(response.getHeaders().getLocation().toString())
|
||||
.doesNotContain("ST-replayed")
|
||||
.doesNotContain("state-replayed");
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import ch.qos.logback.classic.Level;
|
|||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
|
|
@ -38,7 +39,7 @@ class RequestLoggingFilterTest {
|
|||
@Test
|
||||
void doFilterInternal_truncatesLongRequestBodyAndOmitsResponseBody()
|
||||
throws ServletException, IOException {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
RequestLoggingFilter filter = filter();
|
||||
String longBody = "x".repeat(5_000);
|
||||
attachAppender();
|
||||
|
||||
|
|
@ -69,7 +70,7 @@ class RequestLoggingFilterTest {
|
|||
@Test
|
||||
void doFilterInternal_skipsActuatorEndpoints()
|
||||
throws ServletException, IOException {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
RequestLoggingFilter filter = filter();
|
||||
attachAppender();
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/actuator/health");
|
||||
|
|
@ -85,7 +86,7 @@ class RequestLoggingFilterTest {
|
|||
@Test
|
||||
void doFilterInternal_skipsOtherSseEndpointsWithoutWrappingResponse()
|
||||
throws ServletException, IOException {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
RequestLoggingFilter filter = filter();
|
||||
attachAppender();
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/scan/sse");
|
||||
|
|
@ -110,7 +111,7 @@ class RequestLoggingFilterTest {
|
|||
@Test
|
||||
void doFilterInternal_logsCoreSummaryFields()
|
||||
throws ServletException, IOException {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
RequestLoggingFilter filter = filter();
|
||||
attachAppender();
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/skills");
|
||||
|
|
@ -132,7 +133,7 @@ class RequestLoggingFilterTest {
|
|||
|
||||
@Test
|
||||
void doFilterInternal_shouldBypassCachingWrapperForNotificationSse() throws Exception {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
RequestLoggingFilter filter = filter();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicReference<ServletResponse> responseSeenByChain = new AtomicReference<>();
|
||||
|
|
@ -153,7 +154,7 @@ class RequestLoggingFilterTest {
|
|||
|
||||
@Test
|
||||
void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception {
|
||||
RequestLoggingFilter filter = new RequestLoggingFilter();
|
||||
RequestLoggingFilter filter = filter();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/unread-count");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicReference<ServletResponse> responseSeenByChain = new AtomicReference<>();
|
||||
|
|
@ -168,6 +169,46 @@ class RequestLoggingFilterTest {
|
|||
assertThat(response.getContentAsString()).isEqualTo("{\"count\":1}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doFilterInternal_redactsCasQueryAndOmitsAuthBody()
|
||||
throws Exception {
|
||||
RequestLoggingFilter filter = filter();
|
||||
attachAppender();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(
|
||||
"POST",
|
||||
"/api/v1/auth/cas/cas-main/callback");
|
||||
request.setQueryString(
|
||||
"ticket=ST-request-log-secret&state=state-secret"
|
||||
+ "&returnTo=%2Fdashboard");
|
||||
request.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
request.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
request.setContent(
|
||||
"{\"password\":\"body-secret\"}"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response =
|
||||
new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(req, res) -> req.getReader().lines().count());
|
||||
|
||||
assertThat(loggedMessages()).anySatisfy(message -> {
|
||||
assertThat(message)
|
||||
.contains("ticket=[REDACTED]")
|
||||
.contains("state=[REDACTED]")
|
||||
.contains("returnTo=%2Fdashboard")
|
||||
.doesNotContain("ST-request-log-secret")
|
||||
.doesNotContain("state-secret")
|
||||
.doesNotContain("body-secret");
|
||||
});
|
||||
}
|
||||
|
||||
private RequestLoggingFilter filter() {
|
||||
return new RequestLoggingFilter(
|
||||
new SensitiveLogSanitizer());
|
||||
}
|
||||
|
||||
private void attachAppender() {
|
||||
logger.setLevel(Level.INFO);
|
||||
appender = new ListAppender<>();
|
||||
|
|
|
|||
|
|
@ -10,11 +10,34 @@ class SensitiveLogSanitizerTest {
|
|||
|
||||
@Test
|
||||
void shouldRedactSensitiveQueryParameters() {
|
||||
String sanitized = sanitizer.sanitizeQuery("returnTo=%2Fdashboard&token=abc123&password=secret&code=xyz");
|
||||
String sanitized = sanitizer.sanitizeQuery(
|
||||
"returnTo=%2Fdashboard&token=abc123&password=secret"
|
||||
+ "&code=xyz&ticket=ST-secret&state=state-secret");
|
||||
|
||||
assertThat(sanitized).contains("returnTo=%2Fdashboard");
|
||||
assertThat(sanitized).contains("token=[REDACTED]");
|
||||
assertThat(sanitized).contains("password=[REDACTED]");
|
||||
assertThat(sanitized).contains("code=[REDACTED]");
|
||||
assertThat(sanitized).contains("ticket=[REDACTED]");
|
||||
assertThat(sanitized).contains("state=[REDACTED]");
|
||||
assertThat(sanitized)
|
||||
.doesNotContain("ST-secret")
|
||||
.doesNotContain("state-secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRedactEncodedAndMalformedSensitiveKeys() {
|
||||
String sanitized = sanitizer.sanitizeQuery(
|
||||
"ti%63ket=ST-encoded-secret"
|
||||
+ "&st%61te=encoded-state-secret"
|
||||
+ "&bad%=unknown-secret");
|
||||
|
||||
assertThat(sanitized)
|
||||
.contains("ti%63ket=[REDACTED]")
|
||||
.contains("st%61te=[REDACTED]")
|
||||
.contains("bad%=[REDACTED]")
|
||||
.doesNotContain("ST-encoded-secret")
|
||||
.doesNotContain("encoded-state-secret")
|
||||
.doesNotContain("unknown-secret");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,4 +111,38 @@ class AuthMethodCatalogTest {
|
|||
.containsExactly("local-password", "oauth-github");
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsCasAsFixedFirstPartyLoginRoute() {
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
when(registry.listReadyLoginMethods()).thenReturn(List.of(
|
||||
new IdentityProviderLoginMethod(
|
||||
"cas-main",
|
||||
"Corporate CAS",
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT
|
||||
)
|
||||
));
|
||||
|
||||
AuthMethodCatalog catalog = new AuthMethodCatalog(
|
||||
registry,
|
||||
new DirectAuthProperties(),
|
||||
new AuthSessionBootstrapProperties()
|
||||
);
|
||||
|
||||
assertThat(catalog.listMethods("/skills?sort=updated"))
|
||||
.filteredOn(method -> "cas-cas-main".equals(method.id()))
|
||||
.singleElement()
|
||||
.satisfies(method -> {
|
||||
assertThat(method.methodType())
|
||||
.isEqualTo("CAS_REDIRECT");
|
||||
assertThat(method.provider())
|
||||
.isEqualTo("cas-main");
|
||||
assertThat(method.actionUrl())
|
||||
.isEqualTo(
|
||||
"/api/v1/auth/cas/cas-main/login"
|
||||
+ "?returnTo=%2Fskills%3Fsort%3Dupdated"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.cas.CasAuthenticationExchange;
|
||||
import com.iflytek.skillhub.auth.cas.CasBrowserClient;
|
||||
import com.iflytek.skillhub.auth.cas.CasLoginInitiation;
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkActor;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
|
||||
class CasLoginAppServiceTest {
|
||||
|
||||
private static final String PROVIDER = "cas-main";
|
||||
private static final String STATE =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEF0123456789_-";
|
||||
private static final String SERVICE =
|
||||
"https://skill.example/api/v1/auth/cas/cas-main/callback"
|
||||
+ "?state="
|
||||
+ STATE;
|
||||
|
||||
@Test
|
||||
void completesVerifiedExchangeThroughCoreBeforeEstablishingSession() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
CasAuthenticationExchange exchange =
|
||||
new CasAuthenticationExchange(
|
||||
"user-1",
|
||||
Map.of(),
|
||||
Instant.parse("2026-07-31T00:00:00Z"));
|
||||
ProviderAuthenticationResult result = result();
|
||||
PlatformPrincipal principal = principal();
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(consumedState());
|
||||
when(fixture.protocolClient.validate(
|
||||
PROVIDER,
|
||||
"ST-1",
|
||||
SERVICE)).thenReturn(exchange);
|
||||
when(fixture.route.adapter()).thenReturn(fixture.adapter);
|
||||
when(fixture.adapter.authenticate(exchange))
|
||||
.thenReturn(result);
|
||||
when(fixture.providerLogin.authenticate(
|
||||
isNull(),
|
||||
eq(result),
|
||||
eq(request)))
|
||||
.thenReturn(principal);
|
||||
|
||||
assertThat(fixture.service.complete(
|
||||
PROVIDER,
|
||||
"ST-1",
|
||||
STATE,
|
||||
request)).isEqualTo("/skills");
|
||||
|
||||
InOrder order = inOrder(
|
||||
fixture.stateStore,
|
||||
fixture.registry,
|
||||
fixture.protocolClient,
|
||||
fixture.adapter,
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
order.verify(fixture.stateStore).consume(
|
||||
request.getSession().getId(),
|
||||
STATE);
|
||||
order.verify(fixture.registry).requireBrowserRoute(
|
||||
PROVIDER,
|
||||
CasAuthenticationExchange.class);
|
||||
order.verify(fixture.protocolClient).validate(
|
||||
PROVIDER,
|
||||
"ST-1",
|
||||
SERVICE);
|
||||
order.verify(fixture.adapter).authenticate(exchange);
|
||||
order.verify(fixture.providerLogin).authenticate(
|
||||
null,
|
||||
result,
|
||||
request);
|
||||
order.verify(fixture.sessions)
|
||||
.establishSession(principal, request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void beginsWithReadyRouteAndPersistsStateBeforeRedirect() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
URI loginUri = URI.create(
|
||||
"https://cas.example/login?service=fixture");
|
||||
when(fixture.protocolClient.begin(PROVIDER, STATE))
|
||||
.thenReturn(new CasLoginInitiation(
|
||||
loginUri,
|
||||
SERVICE,
|
||||
Duration.ofMinutes(5)));
|
||||
|
||||
assertThat(fixture.service.begin(
|
||||
PROVIDER,
|
||||
"/dashboard",
|
||||
request)).isEqualTo(loginUri);
|
||||
|
||||
InOrder order = inOrder(
|
||||
fixture.registry,
|
||||
fixture.protocolClient,
|
||||
fixture.stateStore);
|
||||
order.verify(fixture.registry).requireBrowserRoute(
|
||||
PROVIDER,
|
||||
CasAuthenticationExchange.class);
|
||||
order.verify(fixture.protocolClient).begin(
|
||||
PROVIDER,
|
||||
STATE);
|
||||
order.verify(fixture.stateStore).save(
|
||||
request.getSession().getId(),
|
||||
STATE,
|
||||
PROVIDER,
|
||||
SERVICE,
|
||||
"/dashboard",
|
||||
Duration.ofMinutes(5));
|
||||
verify(fixture.identityLinkSessionManager)
|
||||
.activateBrowserFlow(
|
||||
request.getSession(),
|
||||
PROVIDER,
|
||||
STATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrReplayedStateNeverReachesCasServer() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(new CasLoginStateStore.ConsumeResult(
|
||||
CasLoginStateStore.ConsumeStatus.NOT_FOUND,
|
||||
null));
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.complete(
|
||||
PROVIDER,
|
||||
"ST-1",
|
||||
STATE,
|
||||
request))
|
||||
.isInstanceOf(CasLoginFlowException.class)
|
||||
.extracting("failure")
|
||||
.isEqualTo(CasLoginFailure.INVALID_STATE);
|
||||
verifyNoInteractions(
|
||||
fixture.protocolClient,
|
||||
fixture.adapter,
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTicketConsumesStateButNeverCallsProvider() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(consumedState());
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.complete(
|
||||
PROVIDER,
|
||||
null,
|
||||
STATE,
|
||||
request))
|
||||
.isInstanceOf(CasLoginFlowException.class)
|
||||
.extracting("failure")
|
||||
.isEqualTo(CasLoginFailure.TICKET_MISSING);
|
||||
verify(fixture.stateStore).consume(
|
||||
request.getSession().getId(),
|
||||
STATE);
|
||||
verify(fixture.registry, never()).requireBrowserRoute(
|
||||
any(),
|
||||
any());
|
||||
verifyNoInteractions(
|
||||
fixture.protocolClient,
|
||||
fixture.adapter,
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsStableUpstreamFailureWithoutCreatingSession() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(consumedState());
|
||||
when(fixture.protocolClient.validate(
|
||||
PROVIDER,
|
||||
"ST-1",
|
||||
SERVICE)).thenThrow(
|
||||
new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.TLS_VALIDATION_FAILED));
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.complete(
|
||||
PROVIDER,
|
||||
"ST-1",
|
||||
STATE,
|
||||
request))
|
||||
.isInstanceOf(CasLoginFlowException.class)
|
||||
.extracting("failure")
|
||||
.isEqualTo(CasLoginFailure.PROVIDER_UNAVAILABLE);
|
||||
verifyNoInteractions(
|
||||
fixture.adapter,
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesAndAuditsReplayedStateWithoutCallingProvider() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(new CasLoginStateStore.ConsumeResult(
|
||||
CasLoginStateStore.ConsumeStatus.REPLAYED,
|
||||
null));
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.complete(
|
||||
PROVIDER,
|
||||
"ST-replayed",
|
||||
STATE,
|
||||
request))
|
||||
.isInstanceOf(CasLoginFlowException.class)
|
||||
.extracting("failure")
|
||||
.isEqualTo(CasLoginFailure.REPLAY_DETECTED);
|
||||
|
||||
verify(fixture.auditLogService).record(
|
||||
null,
|
||||
"IDENTITY_REPLAY_DETECTED",
|
||||
"IDENTITY_PROVIDER",
|
||||
null,
|
||||
null,
|
||||
"127.0.0.1",
|
||||
null,
|
||||
"{\"providerCode\":\"cas-main\","
|
||||
+ "\"protocol\":\"cas\","
|
||||
+ "\"reason\":\"REPLAY_DETECTED\","
|
||||
+ "\"artifact\":\"state\"}");
|
||||
verifyNoInteractions(
|
||||
fixture.protocolClient,
|
||||
fixture.adapter,
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesCasReauthenticationThroughIdentityLinkCore() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
CasAuthenticationExchange exchange =
|
||||
new CasAuthenticationExchange(
|
||||
"user-1",
|
||||
Map.of(),
|
||||
Instant.parse("2026-07-31T00:00:00Z"));
|
||||
ProviderAuthenticationResult result = result();
|
||||
IdentityLinkBrowserFlow flow = linkFlow(
|
||||
IdentityLinkBrowserPhase.REAUTHENTICATE);
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(consumedState());
|
||||
when(fixture.identityLinkSessionManager.consumeBrowserFlow(
|
||||
eq(request),
|
||||
eq(PROVIDER),
|
||||
any(IdentityLoginContext.class)))
|
||||
.thenReturn(Optional.of(flow));
|
||||
when(fixture.protocolClient.validate(
|
||||
PROVIDER,
|
||||
"ST-link",
|
||||
SERVICE)).thenReturn(exchange);
|
||||
when(fixture.route.adapter()).thenReturn(fixture.adapter);
|
||||
when(fixture.adapter.authenticate(exchange))
|
||||
.thenReturn(result);
|
||||
when(fixture.externalIdentityLinkService.reauthenticate(
|
||||
flow.actor(),
|
||||
flow.intentId(),
|
||||
null,
|
||||
result)).thenReturn(
|
||||
new IdentityLinkOutcome.Reauthenticated(
|
||||
principal()));
|
||||
|
||||
assertThat(fixture.service.complete(
|
||||
PROVIDER,
|
||||
"ST-link",
|
||||
STATE,
|
||||
request)).isEqualTo("/skills");
|
||||
|
||||
verify(fixture.externalIdentityLinkService).reauthenticate(
|
||||
flow.actor(),
|
||||
flow.intentId(),
|
||||
null,
|
||||
result);
|
||||
verifyNoInteractions(
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
verify(fixture.identityLinkSessionManager, never())
|
||||
.remove(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsCasIdentityLinkProviderFailureToResumableRedirect() {
|
||||
Fixture fixture = new Fixture();
|
||||
MockHttpServletRequest request = request();
|
||||
IdentityLinkBrowserFlow flow = linkFlow(
|
||||
IdentityLinkBrowserPhase.LINK);
|
||||
when(fixture.stateStore.consume(
|
||||
request.getSession().getId(),
|
||||
STATE)).thenReturn(consumedState());
|
||||
when(fixture.identityLinkSessionManager.consumeBrowserFlow(
|
||||
eq(request),
|
||||
eq(PROVIDER),
|
||||
any(IdentityLoginContext.class)))
|
||||
.thenReturn(Optional.of(flow));
|
||||
when(fixture.protocolClient.validate(
|
||||
PROVIDER,
|
||||
"ST-unavailable",
|
||||
SERVICE)).thenThrow(
|
||||
new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE));
|
||||
|
||||
assertThat(fixture.service.complete(
|
||||
PROVIDER,
|
||||
"ST-unavailable",
|
||||
STATE,
|
||||
request)).isEqualTo(
|
||||
"/settings/security?identityLink=failed"
|
||||
+ "&intentId="
|
||||
+ flow.intentId()
|
||||
+ "&reasonCode="
|
||||
+ IdentityLinkFailureCode
|
||||
.PROVIDER_UNAVAILABLE);
|
||||
|
||||
verifyNoInteractions(
|
||||
fixture.adapter,
|
||||
fixture.externalIdentityLinkService,
|
||||
fixture.providerLogin,
|
||||
fixture.sessions);
|
||||
}
|
||||
|
||||
private static CasLoginStateStore.ConsumeResult
|
||||
consumedState() {
|
||||
return new CasLoginStateStore.ConsumeResult(
|
||||
CasLoginStateStore.ConsumeStatus.CONSUMED,
|
||||
loginState());
|
||||
}
|
||||
|
||||
private static CasLoginStateStore.CasLoginState loginState() {
|
||||
return new CasLoginStateStore.CasLoginState(
|
||||
PROVIDER,
|
||||
SERVICE,
|
||||
"/skills",
|
||||
Instant.parse("2026-07-31T00:05:00Z"));
|
||||
}
|
||||
|
||||
private static IdentityLinkBrowserFlow linkFlow(
|
||||
IdentityLinkBrowserPhase phase) {
|
||||
return new IdentityLinkBrowserFlow(
|
||||
java.util.UUID.fromString(
|
||||
"2302dcb8-0cb3-4da7-a587-b85645ecb834"),
|
||||
phase,
|
||||
new IdentityLinkActor(
|
||||
"usr_1",
|
||||
"local",
|
||||
"session-nonce",
|
||||
new IdentityLoginContext(
|
||||
"req-1",
|
||||
"127.0.0.1",
|
||||
"JUnit")));
|
||||
}
|
||||
|
||||
private static ProviderAuthenticationResult result() {
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(
|
||||
"cas_principal",
|
||||
"user-1"),
|
||||
List.of(),
|
||||
Map.of(),
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"cas",
|
||||
Instant.parse("2026-07-31T00:00:00Z"),
|
||||
Set.of("cas_service_ticket")));
|
||||
}
|
||||
|
||||
private static PlatformPrincipal principal() {
|
||||
return new PlatformPrincipal(
|
||||
"usr_cas",
|
||||
"CAS User",
|
||||
null,
|
||||
null,
|
||||
PROVIDER,
|
||||
Set.of("USER"));
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request() {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest();
|
||||
request.setSession(new MockHttpSession());
|
||||
return request;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
|
||||
private final IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
private final CasBrowserClient protocolClient =
|
||||
mock(CasBrowserClient.class);
|
||||
private final ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
private final ExternalIdentityLinkService
|
||||
externalIdentityLinkService =
|
||||
mock(ExternalIdentityLinkService.class);
|
||||
private final IdentityLinkSessionManager
|
||||
identityLinkSessionManager =
|
||||
mock(IdentityLinkSessionManager.class);
|
||||
private final PlatformSessionService sessions =
|
||||
mock(PlatformSessionService.class);
|
||||
private final CasLoginStateStore stateStore =
|
||||
mock(CasLoginStateStore.class);
|
||||
private final AuditLogService auditLogService =
|
||||
mock(AuditLogService.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
private final IdentityProviderRegistry.BrowserRoute
|
||||
<CasAuthenticationExchange> route =
|
||||
mock(IdentityProviderRegistry.BrowserRoute.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
private final BrowserAuthenticationAdapter
|
||||
<CasAuthenticationExchange> adapter =
|
||||
mock(BrowserAuthenticationAdapter.class);
|
||||
private final CasLoginAppService service =
|
||||
new CasLoginAppService(
|
||||
registry,
|
||||
protocolClient,
|
||||
providerLogin,
|
||||
externalIdentityLinkService,
|
||||
identityLinkSessionManager,
|
||||
sessions,
|
||||
stateStore,
|
||||
auditLogService,
|
||||
() -> STATE);
|
||||
|
||||
private Fixture() {
|
||||
when(registry.requireBrowserRoute(
|
||||
PROVIDER,
|
||||
CasAuthenticationExchange.class))
|
||||
.thenReturn(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
class CasLoginStateStoreTest {
|
||||
|
||||
private static final Instant NOW =
|
||||
Instant.parse("2026-07-31T00:00:00Z");
|
||||
private static final String SESSION_ID =
|
||||
"session-secret";
|
||||
private static final String STATE =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEF0123456789_-";
|
||||
|
||||
@Test
|
||||
void savesHashedKeyAndConsumesStateAtomically() {
|
||||
StringRedisTemplate redis = mock(
|
||||
StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> values =
|
||||
mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
ObjectMapper objectMapper = new ObjectMapper()
|
||||
.findAndRegisterModules();
|
||||
CasLoginStateStore store = new CasLoginStateStore(
|
||||
redis,
|
||||
objectMapper,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
Duration ttl = Duration.ofMinutes(5);
|
||||
|
||||
store.save(
|
||||
SESSION_ID,
|
||||
STATE,
|
||||
"cas-main",
|
||||
"https://skill.example/api/v1/auth/cas/cas-main/callback"
|
||||
+ "?state="
|
||||
+ STATE,
|
||||
"/dashboard",
|
||||
ttl);
|
||||
|
||||
ArgumentCaptor<String> key =
|
||||
ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> serialized =
|
||||
ArgumentCaptor.forClass(String.class);
|
||||
verify(values).set(
|
||||
key.capture(),
|
||||
serialized.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(ttl));
|
||||
assertThat(key.getValue())
|
||||
.startsWith("skillhub:auth:cas:state:")
|
||||
.doesNotContain(SESSION_ID)
|
||||
.doesNotContain(STATE);
|
||||
assertThat(serialized.getValue()).startsWith("P:");
|
||||
|
||||
when(redis.execute(
|
||||
org.mockito.ArgumentMatchers
|
||||
.<RedisScript<String>>any(),
|
||||
eq(List.of(key.getValue()))))
|
||||
.thenReturn(serialized.getValue())
|
||||
.thenReturn("R")
|
||||
.thenReturn((String) null);
|
||||
|
||||
CasLoginStateStore.ConsumeResult consumed =
|
||||
store.consume(SESSION_ID, STATE);
|
||||
assertThat(consumed.status())
|
||||
.isEqualTo(
|
||||
CasLoginStateStore.ConsumeStatus.CONSUMED);
|
||||
assertThat(consumed.state())
|
||||
.extracting(
|
||||
CasLoginStateStore.CasLoginState::providerCode,
|
||||
CasLoginStateStore.CasLoginState::returnTo)
|
||||
.containsExactly("cas-main", "/dashboard");
|
||||
assertThat(store.consume(SESSION_ID, STATE).status())
|
||||
.isEqualTo(
|
||||
CasLoginStateStore.ConsumeStatus.REPLAYED);
|
||||
assertThat(store.consume(SESSION_ID, STATE).status())
|
||||
.isEqualTo(
|
||||
CasLoginStateStore.ConsumeStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExpiredOrMalformedStateWithoutReturningIt() throws Exception {
|
||||
StringRedisTemplate redis = mock(
|
||||
StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> values =
|
||||
mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
ObjectMapper objectMapper = new ObjectMapper()
|
||||
.findAndRegisterModules();
|
||||
CasLoginStateStore store = new CasLoginStateStore(
|
||||
redis,
|
||||
objectMapper,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
String expired = objectMapper.writeValueAsString(
|
||||
new CasLoginStateStore.CasLoginState(
|
||||
"cas-main",
|
||||
"https://skill.example/callback",
|
||||
null,
|
||||
NOW.minusSeconds(1)));
|
||||
when(redis.execute(
|
||||
org.mockito.ArgumentMatchers
|
||||
.<RedisScript<String>>any(),
|
||||
anyList()))
|
||||
.thenReturn("P:" + expired);
|
||||
|
||||
assertThat(store.consume(SESSION_ID, STATE).status())
|
||||
.isEqualTo(
|
||||
CasLoginStateStore.ConsumeStatus.NOT_FOUND);
|
||||
assertThat(store.consume(
|
||||
SESSION_ID,
|
||||
"not valid").status())
|
||||
.isEqualTo(
|
||||
CasLoginStateStore.ConsumeStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenRedisCannotPersistOrConsumeState() {
|
||||
StringRedisTemplate redis = mock(
|
||||
StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> values =
|
||||
mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
CasLoginStateStore store = new CasLoginStateStore(
|
||||
redis,
|
||||
new ObjectMapper().findAndRegisterModules(),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
org.mockito.Mockito.doThrow(
|
||||
new IllegalStateException("redis unavailable"))
|
||||
.when(values)
|
||||
.set(
|
||||
anyString(),
|
||||
anyString(),
|
||||
org.mockito.ArgumentMatchers.any(
|
||||
Duration.class));
|
||||
|
||||
assertThatThrownBy(() -> store.save(
|
||||
SESSION_ID,
|
||||
STATE,
|
||||
"cas-main",
|
||||
"https://skill.example/callback",
|
||||
null,
|
||||
Duration.ofMinutes(5)))
|
||||
.isInstanceOf(
|
||||
CasLoginStateStore
|
||||
.CasLoginStateStoreException.class);
|
||||
|
||||
when(redis.execute(
|
||||
org.mockito.ArgumentMatchers
|
||||
.<RedisScript<String>>any(),
|
||||
anyList()))
|
||||
.thenThrow(new IllegalStateException(
|
||||
"redis unavailable"));
|
||||
assertThatThrownBy(
|
||||
() -> store.consume(SESSION_ID, STATE))
|
||||
.isInstanceOf(
|
||||
CasLoginStateStore
|
||||
.CasLoginStateStoreException.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.IdentityLinkOperation;
|
||||
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkActor;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkIntent;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkIntentService;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IdentityLinkAppServiceTest {
|
||||
|
||||
private static final UUID INTENT_ID = UUID.fromString(
|
||||
"b731a62c-c168-4d58-8fcb-2f2461748d04");
|
||||
private static final String PROVIDER = "cas-main";
|
||||
|
||||
@Test
|
||||
void preparesCasReauthenticationWithTheSharedBrowserIntent() {
|
||||
Fixture fixture = new Fixture();
|
||||
IdentityLinkIntent pending = intent(
|
||||
IdentityLinkRequestStatus.PENDING_REAUTHENTICATION);
|
||||
when(fixture.registry.listReadyLoginMethods())
|
||||
.thenReturn(List.of(casMethod()));
|
||||
when(fixture.intentService.prepareExternalReauthentication(
|
||||
fixture.actor,
|
||||
INTENT_ID,
|
||||
PROVIDER,
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT))
|
||||
.thenReturn(pending);
|
||||
|
||||
String actionUrl =
|
||||
fixture.service.prepareBrowserReauthentication(
|
||||
INTENT_ID,
|
||||
PROVIDER,
|
||||
fixture.session,
|
||||
fixture.context);
|
||||
assertCasAction(
|
||||
actionUrl,
|
||||
"/settings/security?identityLink=reauthenticated"
|
||||
+ "&intentId="
|
||||
+ INTENT_ID);
|
||||
|
||||
verify(fixture.sessionManager).prepareBrowserFlow(
|
||||
fixture.session,
|
||||
INTENT_ID,
|
||||
IdentityLinkBrowserPhase.REAUTHENTICATE,
|
||||
PROVIDER,
|
||||
fixture.context);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparesCasTargetAuthenticationAfterFreshReauthentication() {
|
||||
Fixture fixture = new Fixture();
|
||||
IdentityLinkIntent ready = intent(
|
||||
IdentityLinkRequestStatus.READY);
|
||||
when(fixture.registry.listReadyLoginMethods())
|
||||
.thenReturn(List.of(casMethod()));
|
||||
when(fixture.intentService.getIntent(
|
||||
fixture.actor,
|
||||
INTENT_ID)).thenReturn(ready);
|
||||
when(fixture.intentService.prepareExternalLink(
|
||||
fixture.actor,
|
||||
INTENT_ID,
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT))
|
||||
.thenReturn(ready);
|
||||
|
||||
String actionUrl = fixture.service.prepareBrowserLink(
|
||||
INTENT_ID,
|
||||
fixture.session,
|
||||
fixture.context);
|
||||
assertCasAction(
|
||||
actionUrl,
|
||||
"/settings/security?identityLink=linked"
|
||||
+ "&intentId="
|
||||
+ INTENT_ID);
|
||||
|
||||
verify(fixture.sessionManager).prepareBrowserFlow(
|
||||
fixture.session,
|
||||
INTENT_ID,
|
||||
IdentityLinkBrowserPhase.LINK,
|
||||
PROVIDER,
|
||||
fixture.context);
|
||||
}
|
||||
|
||||
@Test
|
||||
void browserEndpointRejectsCredentialOnlyProvider() {
|
||||
Fixture fixture = new Fixture();
|
||||
when(fixture.registry.listReadyLoginMethods())
|
||||
.thenReturn(List.of(new IdentityProviderLoginMethod(
|
||||
PROVIDER,
|
||||
"Corporate Directory",
|
||||
IdentityProviderLoginMethodType
|
||||
.DIRECT_PASSWORD)));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
fixture.service.prepareBrowserReauthentication(
|
||||
INTENT_ID,
|
||||
PROVIDER,
|
||||
fixture.session,
|
||||
fixture.context))
|
||||
.isInstanceOfSatisfying(
|
||||
IdentityLinkException.class,
|
||||
exception -> assertThat(
|
||||
exception.getReasonCode())
|
||||
.isEqualTo(
|
||||
IdentityLinkFailureCode
|
||||
.PROVIDER_UNAVAILABLE));
|
||||
}
|
||||
|
||||
private static IdentityProviderLoginMethod casMethod() {
|
||||
return new IdentityProviderLoginMethod(
|
||||
PROVIDER,
|
||||
"Corporate CAS",
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT);
|
||||
}
|
||||
|
||||
private static void assertCasAction(
|
||||
String actionUrl,
|
||||
String expectedReturnTo) {
|
||||
URI uri = URI.create(actionUrl);
|
||||
assertThat(uri.getPath()).isEqualTo(
|
||||
"/api/v1/auth/cas/cas-main/login");
|
||||
assertThat(uri.getRawQuery()).startsWith("returnTo=");
|
||||
assertThat(URLDecoder.decode(
|
||||
uri.getRawQuery().substring("returnTo=".length()),
|
||||
StandardCharsets.UTF_8))
|
||||
.isEqualTo(expectedReturnTo);
|
||||
}
|
||||
|
||||
private static IdentityLinkIntent intent(
|
||||
IdentityLinkRequestStatus status) {
|
||||
return new IdentityLinkIntent(
|
||||
INTENT_ID,
|
||||
IdentityLinkOperation.LINK,
|
||||
status,
|
||||
PROVIDER,
|
||||
null,
|
||||
Instant.parse("2026-07-31T08:10:00Z"));
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
|
||||
private final IdentityLinkIntentService intentService =
|
||||
mock(IdentityLinkIntentService.class);
|
||||
private final ExternalIdentityLinkService externalLinkService =
|
||||
mock(ExternalIdentityLinkService.class);
|
||||
private final IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
private final IdentityLinkSessionManager sessionManager =
|
||||
mock(IdentityLinkSessionManager.class);
|
||||
private final HttpSession session = mock(HttpSession.class);
|
||||
private final IdentityLoginContext context =
|
||||
new IdentityLoginContext(
|
||||
"req-1",
|
||||
"127.0.0.1",
|
||||
"JUnit");
|
||||
private final IdentityLinkActor actor =
|
||||
new IdentityLinkActor(
|
||||
"usr_1",
|
||||
"local",
|
||||
"session-nonce",
|
||||
context);
|
||||
private final IdentityLinkAppService service =
|
||||
new IdentityLinkAppService(
|
||||
intentService,
|
||||
externalLinkService,
|
||||
registry,
|
||||
sessionManager);
|
||||
|
||||
private Fixture() {
|
||||
when(sessionManager.actor(
|
||||
session,
|
||||
INTENT_ID,
|
||||
context)).thenReturn(actor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
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.BrowserAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationMethod;
|
||||
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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Maps one verified CAS exchange into provider facts consumed by the unified
|
||||
* identity core.
|
||||
*/
|
||||
@Component
|
||||
public final class CasAuthenticationAdapter
|
||||
implements BrowserAuthenticationAdapter<CasAuthenticationExchange> {
|
||||
|
||||
static final String DISPLAY_NAME_ATTRIBUTE = "cas_display_name";
|
||||
static final String EMAIL_ATTRIBUTE = "cas_email";
|
||||
static final String AVATAR_ATTRIBUTE = "cas_avatar_url";
|
||||
private static final int MAX_SUBJECT_LENGTH = 4096;
|
||||
|
||||
private final CasProviderConfiguration configuration;
|
||||
|
||||
CasAuthenticationAdapter(
|
||||
CasProviderConfiguration configuration) {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
if (!configuration.enabled()) {
|
||||
return new ProviderInstanceDefinition(
|
||||
"cas",
|
||||
"cas",
|
||||
"disabled",
|
||||
"CAS",
|
||||
"cas_principal",
|
||||
"cas_principal",
|
||||
Map.of(
|
||||
"cas_principal",
|
||||
SubjectNormalization.EXACT),
|
||||
List.of(DISPLAY_NAME_ATTRIBUTE),
|
||||
List.of(EMAIL_ATTRIBUTE),
|
||||
List.of(AVATAR_ATTRIBUTE),
|
||||
EmailAssurance.PROVIDER_ASSERTED,
|
||||
false);
|
||||
}
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved =
|
||||
configuration.requireResolved();
|
||||
return new ProviderInstanceDefinition(
|
||||
resolved.providerCode(),
|
||||
"cas",
|
||||
resolved.authority(),
|
||||
resolved.displayName(),
|
||||
resolved.subjectType(),
|
||||
resolved.subjectType(),
|
||||
Map.of(
|
||||
resolved.subjectType(),
|
||||
SubjectNormalization.EXACT),
|
||||
List.of(DISPLAY_NAME_ATTRIBUTE),
|
||||
List.of(EMAIL_ATTRIBUTE),
|
||||
List.of(AVATAR_ATTRIBUTE),
|
||||
EmailAssurance.PROVIDER_ASSERTED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<CasAuthenticationExchange> exchangeType() {
|
||||
return CasAuthenticationExchange.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BrowserAuthenticationMethod loginMethod() {
|
||||
return BrowserAuthenticationMethod.CAS_REDIRECT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAuthenticationResult authenticate(
|
||||
CasAuthenticationExchange exchange) {
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved =
|
||||
configuration.requireResolved();
|
||||
String subject = resolved.subjectAttribute()
|
||||
.map(attribute -> requireUniqueSubject(
|
||||
exchange.attributes().get(attribute)))
|
||||
.orElse(exchange.principal());
|
||||
|
||||
Map<String, List<ProviderAttributeValue>> attributes =
|
||||
new LinkedHashMap<>();
|
||||
putMapped(
|
||||
attributes,
|
||||
DISPLAY_NAME_ATTRIBUTE,
|
||||
resolved.displayNameAttribute(),
|
||||
exchange.attributes(),
|
||||
ProviderAttributeTrust.ASSERTED);
|
||||
putMapped(
|
||||
attributes,
|
||||
EMAIL_ATTRIBUTE,
|
||||
resolved.emailAttribute(),
|
||||
exchange.attributes(),
|
||||
ProviderAttributeTrust.ASSERTED);
|
||||
putMapped(
|
||||
attributes,
|
||||
AVATAR_ATTRIBUTE,
|
||||
resolved.avatarAttribute(),
|
||||
exchange.attributes(),
|
||||
ProviderAttributeTrust.ASSERTED);
|
||||
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(
|
||||
resolved.subjectType(),
|
||||
subject),
|
||||
List.of(),
|
||||
attributes,
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"cas",
|
||||
exchange.authenticatedAt(),
|
||||
Set.of("cas_service_ticket")));
|
||||
}
|
||||
|
||||
private String requireUniqueSubject(List<String> values) {
|
||||
if (values == null || values.size() != 1
|
||||
|| values.getFirst().isBlank()
|
||||
|| values.getFirst().length()
|
||||
> MAX_SUBJECT_LENGTH) {
|
||||
throw new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
return values.getFirst();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
String first = values.stream()
|
||||
.filter(value -> !value.isBlank())
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (first != null) {
|
||||
target.put(
|
||||
targetKey,
|
||||
List.of(new ProviderAttributeValue(
|
||||
first,
|
||||
trust)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Verified CAS service response. It intentionally contains no service ticket,
|
||||
* raw response, platform identity, role, or session object.
|
||||
*/
|
||||
public record CasAuthenticationExchange(
|
||||
String principal,
|
||||
Map<String, List<String>> attributes,
|
||||
Instant authenticatedAt
|
||||
) {
|
||||
public CasAuthenticationExchange {
|
||||
Objects.requireNonNull(principal, "principal");
|
||||
Objects.requireNonNull(attributes, "attributes");
|
||||
Objects.requireNonNull(authenticatedAt, "authenticatedAt");
|
||||
if (principal.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"CAS principal is required");
|
||||
}
|
||||
|
||||
LinkedHashMap<String, List<String>> copied =
|
||||
new LinkedHashMap<>();
|
||||
attributes.forEach((key, values) -> {
|
||||
Objects.requireNonNull(key, "CAS attribute key");
|
||||
List<String> copiedValues = List.copyOf(values);
|
||||
if (copiedValues.stream().anyMatch(Objects::isNull)) {
|
||||
throw new IllegalArgumentException(
|
||||
"CAS attribute values cannot contain null");
|
||||
}
|
||||
copied.put(key, copiedValues);
|
||||
});
|
||||
attributes = Map.copyOf(copied);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
/**
|
||||
* Application-facing CAS browser protocol boundary.
|
||||
*/
|
||||
public interface CasBrowserClient {
|
||||
|
||||
CasLoginInitiation begin(
|
||||
String providerCode,
|
||||
String state);
|
||||
|
||||
CasAuthenticationExchange validate(
|
||||
String providerCode,
|
||||
String ticket,
|
||||
String expectedServiceUrl);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* First-party CAS login projection and the exact service value that must be
|
||||
* reused for service-ticket validation.
|
||||
*/
|
||||
public record CasLoginInitiation(
|
||||
URI loginUri,
|
||||
String serviceUrl,
|
||||
Duration stateTtl
|
||||
) {
|
||||
public CasLoginInitiation {
|
||||
Objects.requireNonNull(loginUri, "loginUri");
|
||||
Objects.requireNonNull(serviceUrl, "serviceUrl");
|
||||
Objects.requireNonNull(stateTtl, "stateTtl");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Static configuration for the built-in CAS provider instance.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.auth.cas")
|
||||
public class CasProperties {
|
||||
|
||||
private boolean enabled;
|
||||
private String providerCode = "cas";
|
||||
private String displayName = "CAS";
|
||||
private String authority;
|
||||
private String serverUrl;
|
||||
private String serviceUrl;
|
||||
private String protocolVersion = "3.0";
|
||||
private String subjectType = "cas_principal";
|
||||
private boolean allowInsecureForTesting;
|
||||
private Duration connectTimeout = Duration.ofSeconds(5);
|
||||
private Duration readTimeout = Duration.ofSeconds(10);
|
||||
private Duration stateTtl = Duration.ofMinutes(5);
|
||||
private int maxResponseBytes = 1024 * 1024;
|
||||
private Attributes attributes = new Attributes();
|
||||
|
||||
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 getServerUrl() {
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
public void setServerUrl(String serverUrl) {
|
||||
this.serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
public String getServiceUrl() {
|
||||
return serviceUrl;
|
||||
}
|
||||
|
||||
public void setServiceUrl(String serviceUrl) {
|
||||
this.serviceUrl = serviceUrl;
|
||||
}
|
||||
|
||||
public String getProtocolVersion() {
|
||||
return protocolVersion;
|
||||
}
|
||||
|
||||
public void setProtocolVersion(String protocolVersion) {
|
||||
this.protocolVersion = protocolVersion;
|
||||
}
|
||||
|
||||
public String getSubjectType() {
|
||||
return subjectType;
|
||||
}
|
||||
|
||||
public void setSubjectType(String subjectType) {
|
||||
this.subjectType = subjectType;
|
||||
}
|
||||
|
||||
public boolean isAllowInsecureForTesting() {
|
||||
return allowInsecureForTesting;
|
||||
}
|
||||
|
||||
public void setAllowInsecureForTesting(
|
||||
boolean allowInsecureForTesting) {
|
||||
this.allowInsecureForTesting = allowInsecureForTesting;
|
||||
}
|
||||
|
||||
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 getStateTtl() {
|
||||
return stateTtl;
|
||||
}
|
||||
|
||||
public void setStateTtl(Duration stateTtl) {
|
||||
this.stateTtl = stateTtl;
|
||||
}
|
||||
|
||||
public int getMaxResponseBytes() {
|
||||
return maxResponseBytes;
|
||||
}
|
||||
|
||||
public void setMaxResponseBytes(int maxResponseBytes) {
|
||||
this.maxResponseBytes = maxResponseBytes;
|
||||
}
|
||||
|
||||
public Attributes getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public void setAttributes(Attributes attributes) {
|
||||
this.attributes = attributes == null
|
||||
? new Attributes()
|
||||
: attributes;
|
||||
}
|
||||
|
||||
public static final class Attributes {
|
||||
|
||||
private String subject;
|
||||
private String displayName;
|
||||
private String email;
|
||||
private String avatarUrl;
|
||||
|
||||
public String getSubject() {
|
||||
return subject;
|
||||
}
|
||||
|
||||
public void setSubject(String subject) {
|
||||
this.subject = subject;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
public void setAvatarUrl(String avatarUrl) {
|
||||
this.avatarUrl = avatarUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,762 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.StringReader;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Flow;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.UriComponents;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.xml.sax.ErrorHandler;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
import org.xml.sax.SAXParseException;
|
||||
|
||||
/**
|
||||
* CAS redirect construction and service-ticket validation.
|
||||
*
|
||||
* <p>The implementation intentionally does not use Apereo's URL-based
|
||||
* validator because that client logs the full validation URL and response at
|
||||
* DEBUG level. The URL contains the one-time service ticket. This client keeps
|
||||
* transport, parsing and failures credential-free while preserving CAS
|
||||
* 2.0/3.0 protocol behavior.</p>
|
||||
*/
|
||||
@Component
|
||||
public final class CasProtocolClient implements CasBrowserClient {
|
||||
|
||||
private static final int MAX_TICKET_LENGTH = 2048;
|
||||
private static final int MAX_SERVICE_LENGTH = 4096;
|
||||
private static final int MAX_ATTRIBUTE_COUNT = 128;
|
||||
private static final int MAX_VALUES_PER_ATTRIBUTE = 32;
|
||||
private static final int MAX_ATTRIBUTE_VALUE_LENGTH = 4096;
|
||||
private static final String CAS_NAMESPACE =
|
||||
"http://www.yale.edu/tp/cas";
|
||||
private static final Pattern STATE_PATTERN =
|
||||
Pattern.compile("[A-Za-z0-9_-]{32,128}");
|
||||
|
||||
private final CasProviderConfiguration configuration;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final CasValidationTransport transport;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
CasProtocolClient(
|
||||
CasProviderConfiguration configuration,
|
||||
ObjectMapper objectMapper) {
|
||||
this(
|
||||
configuration,
|
||||
objectMapper,
|
||||
JdkCasValidationTransport::exchange,
|
||||
Clock.systemUTC());
|
||||
}
|
||||
|
||||
CasProtocolClient(
|
||||
CasProviderConfiguration configuration,
|
||||
ObjectMapper objectMapper,
|
||||
CasValidationTransport transport,
|
||||
Clock clock) {
|
||||
this.configuration = configuration;
|
||||
this.objectMapper = objectMapper;
|
||||
this.transport = transport;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CasLoginInitiation begin(
|
||||
String providerCode,
|
||||
String state) {
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved =
|
||||
requireProvider(providerCode);
|
||||
if (state == null
|
||||
|| !STATE_PATTERN.matcher(state).matches()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
|
||||
String serviceUrl = resolved.serviceUri().toASCIIString()
|
||||
+ "?state="
|
||||
+ state;
|
||||
URI loginEndpoint = appendPath(
|
||||
resolved.serverUri(),
|
||||
"/login");
|
||||
URI loginUri = UriComponentsBuilder
|
||||
.fromUri(loginEndpoint)
|
||||
.queryParam("service", serviceUrl)
|
||||
.build()
|
||||
.encode()
|
||||
.toUri();
|
||||
return new CasLoginInitiation(
|
||||
loginUri,
|
||||
serviceUrl,
|
||||
resolved.stateTtl());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CasAuthenticationExchange validate(
|
||||
String providerCode,
|
||||
String ticket,
|
||||
String expectedServiceUrl) {
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved =
|
||||
requireProvider(providerCode);
|
||||
requireTicket(ticket);
|
||||
requireExpectedService(resolved, expectedServiceUrl);
|
||||
|
||||
URI endpoint = appendPath(
|
||||
resolved.serverUri(),
|
||||
resolved.protocolVersion().validationPath());
|
||||
UriComponentsBuilder validationUri =
|
||||
UriComponentsBuilder.fromUri(endpoint)
|
||||
.queryParam("ticket", ticket)
|
||||
.queryParam("service", expectedServiceUrl);
|
||||
if (resolved.protocolVersion().jsonPreferred()) {
|
||||
validationUri.queryParam("format", "JSON");
|
||||
}
|
||||
|
||||
CasValidationResponse response;
|
||||
try {
|
||||
response = transport.exchange(
|
||||
validationUri.build().encode().toUri(),
|
||||
resolved.connectTimeout(),
|
||||
resolved.readTimeout(),
|
||||
resolved.maximumResponseBytes());
|
||||
} catch (CasTransportFailure failure) {
|
||||
throw new ProviderAuthenticationException(
|
||||
failure.reasonCode());
|
||||
}
|
||||
|
||||
requireSuccessStatus(response.statusCode());
|
||||
if (response.body() == null || response.body().isBlank()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return parseResponse(
|
||||
resolved.protocolVersion(),
|
||||
response.body());
|
||||
}
|
||||
|
||||
private CasProviderConfiguration.ResolvedCasProvider requireProvider(
|
||||
String providerCode) {
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved;
|
||||
try {
|
||||
resolved = configuration.requireResolved();
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_MISCONFIGURED);
|
||||
}
|
||||
if (!resolved.providerCode().equals(providerCode)) {
|
||||
throw new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_MISCONFIGURED);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private void requireTicket(String ticket) {
|
||||
if (ticket == null
|
||||
|| ticket.isBlank()
|
||||
|| ticket.length() > MAX_TICKET_LENGTH
|
||||
|| ticket.chars().anyMatch(character ->
|
||||
Character.isISOControl(character)
|
||||
|| Character.isWhitespace(character))) {
|
||||
throw new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireExpectedService(
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved,
|
||||
String expectedServiceUrl) {
|
||||
if (expectedServiceUrl == null
|
||||
|| expectedServiceUrl.length() > MAX_SERVICE_LENGTH) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
UriComponents expected;
|
||||
try {
|
||||
expected = UriComponentsBuilder
|
||||
.fromUriString(expectedServiceUrl)
|
||||
.build();
|
||||
} catch (RuntimeException exception) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
URI base = resolved.serviceUri();
|
||||
if (!base.getScheme().equals(expected.getScheme())
|
||||
|| !base.getHost().equals(expected.getHost())
|
||||
|| base.getPort() != expected.getPort()
|
||||
|| !base.getPath().equals(expected.getPath())
|
||||
|| expected.getFragment() != null
|
||||
|| expected.getQueryParams().size() != 1
|
||||
|| expected.getQueryParams().get("state") == null
|
||||
|| expected.getQueryParams().get("state").size() != 1
|
||||
|| !STATE_PATTERN.matcher(expected.getQueryParams()
|
||||
.getFirst("state")).matches()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSuccessStatus(int statusCode) {
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return;
|
||||
}
|
||||
ProviderAuthenticationFailureCode code;
|
||||
if (statusCode == 401) {
|
||||
code = ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS;
|
||||
} else if (statusCode == 403) {
|
||||
code = ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_ACCESS_DENIED;
|
||||
} else if (statusCode >= 500) {
|
||||
code = ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE;
|
||||
} else {
|
||||
code = ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE;
|
||||
}
|
||||
throw new ProviderAuthenticationException(code);
|
||||
}
|
||||
|
||||
private CasAuthenticationExchange parseResponse(
|
||||
CasProtocolVersion protocolVersion,
|
||||
String response) {
|
||||
try {
|
||||
String stripped = response.stripLeading();
|
||||
if (protocolVersion.jsonPreferred()
|
||||
&& stripped.startsWith("{")) {
|
||||
return parseJson(response);
|
||||
}
|
||||
if (stripped.startsWith("<")) {
|
||||
return parseXml(response);
|
||||
}
|
||||
throw invalidResponse();
|
||||
} catch (CasAuthenticationFailure failure) {
|
||||
throw new ProviderAuthenticationException(
|
||||
failureCode(failure.code()));
|
||||
} catch (ProviderAuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private CasAuthenticationExchange parseJson(String response) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(response);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
JsonNode serviceResponse = root.path("serviceResponse");
|
||||
JsonNode success =
|
||||
serviceResponse.path("authenticationSuccess");
|
||||
JsonNode failure =
|
||||
serviceResponse.path("authenticationFailure");
|
||||
if (!success.isMissingNode() && !failure.isMissingNode()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
if (!failure.isMissingNode()) {
|
||||
throw new CasAuthenticationFailure(
|
||||
textValue(failure.path("code"), "UNKNOWN"));
|
||||
}
|
||||
if (!success.isObject()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
String principal = requiredText(success.path("user"));
|
||||
Map<String, List<String>> attributes =
|
||||
parseJsonAttributes(success.path("attributes"));
|
||||
return new CasAuthenticationExchange(
|
||||
principal,
|
||||
attributes,
|
||||
clock.instant());
|
||||
}
|
||||
|
||||
private Map<String, List<String>> parseJsonAttributes(
|
||||
JsonNode attributesNode) {
|
||||
if (attributesNode.isMissingNode() || attributesNode.isNull()) {
|
||||
return Map.of();
|
||||
}
|
||||
if (!attributesNode.isObject()
|
||||
|| attributesNode.size() > MAX_ATTRIBUTE_COUNT) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
Map<String, List<String>> attributes = new LinkedHashMap<>();
|
||||
attributesNode.fields().forEachRemaining(entry -> {
|
||||
JsonNode raw = entry.getValue();
|
||||
List<String> values = new ArrayList<>();
|
||||
if (raw.isValueNode() && !raw.isNull()) {
|
||||
values.add(scalarValue(raw));
|
||||
} else if (raw.isArray()
|
||||
&& raw.size() <= MAX_VALUES_PER_ATTRIBUTE) {
|
||||
raw.forEach(value -> {
|
||||
if (!value.isValueNode() || value.isNull()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
values.add(scalarValue(value));
|
||||
});
|
||||
} else {
|
||||
throw invalidResponse();
|
||||
}
|
||||
attributes.put(entry.getKey(), List.copyOf(values));
|
||||
});
|
||||
return Map.copyOf(attributes);
|
||||
}
|
||||
|
||||
private String scalarValue(JsonNode node) {
|
||||
String value = node.asText();
|
||||
if (value.length() > MAX_ATTRIBUTE_VALUE_LENGTH) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private CasAuthenticationExchange parseXml(String response) {
|
||||
DocumentBuilderFactory factory =
|
||||
DocumentBuilderFactory.newInstance();
|
||||
configureSecureXml(factory);
|
||||
try {
|
||||
var builder = factory.newDocumentBuilder();
|
||||
builder.setErrorHandler(new StrictXmlErrorHandler());
|
||||
var document = builder.parse(
|
||||
new InputSource(new StringReader(response)));
|
||||
Element root = document.getDocumentElement();
|
||||
if (root == null
|
||||
|| !"serviceResponse".equals(
|
||||
root.getLocalName())
|
||||
|| !CAS_NAMESPACE.equals(
|
||||
root.getNamespaceURI())) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
List<Element> successes = childElements(
|
||||
root,
|
||||
"authenticationSuccess");
|
||||
List<Element> failures = childElements(
|
||||
root,
|
||||
"authenticationFailure");
|
||||
if (!successes.isEmpty() && !failures.isEmpty()) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
if (!failures.isEmpty()) {
|
||||
throw new CasAuthenticationFailure(
|
||||
failures.getFirst().getAttribute("code"));
|
||||
}
|
||||
if (successes.size() != 1) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
Element success = successes.getFirst();
|
||||
List<Element> users = childElements(success, "user");
|
||||
if (users.size() != 1) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
String principal = users.getFirst().getTextContent();
|
||||
if (principal == null
|
||||
|| principal.isBlank()
|
||||
|| principal.length()
|
||||
> MAX_ATTRIBUTE_VALUE_LENGTH) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
List<Element> attributesElements =
|
||||
childElements(success, "attributes");
|
||||
if (attributesElements.size() > 1) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
Map<String, List<String>> attributes =
|
||||
attributesElements.isEmpty()
|
||||
? Map.of()
|
||||
: parseXmlAttributes(
|
||||
attributesElements.getFirst());
|
||||
return new CasAuthenticationExchange(
|
||||
principal,
|
||||
attributes,
|
||||
clock.instant());
|
||||
} catch (CasAuthenticationFailure failure) {
|
||||
throw failure;
|
||||
} catch (ProviderAuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private void configureSecureXml(
|
||||
DocumentBuilderFactory factory) {
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
try {
|
||||
factory.setFeature(
|
||||
XMLConstants.FEATURE_SECURE_PROCESSING,
|
||||
true);
|
||||
factory.setFeature(
|
||||
"http://apache.org/xml/features/disallow-doctype-decl",
|
||||
true);
|
||||
factory.setFeature(
|
||||
"http://xml.org/sax/features/external-general-entities",
|
||||
false);
|
||||
factory.setFeature(
|
||||
"http://xml.org/sax/features/external-parameter-entities",
|
||||
false);
|
||||
factory.setFeature(
|
||||
"http://apache.org/xml/features/nonvalidating/load-external-dtd",
|
||||
false);
|
||||
factory.setAttribute(
|
||||
XMLConstants.ACCESS_EXTERNAL_DTD,
|
||||
"");
|
||||
factory.setAttribute(
|
||||
XMLConstants.ACCESS_EXTERNAL_SCHEMA,
|
||||
"");
|
||||
} catch (ParserConfigurationException
|
||||
| IllegalArgumentException exception) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<String>> parseXmlAttributes(
|
||||
Element attributesElement) {
|
||||
Map<String, List<String>> attributes =
|
||||
new LinkedHashMap<>();
|
||||
for (Element element : childElements(attributesElement, null)) {
|
||||
String name = element.getLocalName();
|
||||
String value = element.getTextContent();
|
||||
if (name == null
|
||||
|| value == null
|
||||
|| value.length() > MAX_ATTRIBUTE_VALUE_LENGTH) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
List<String> existing = attributes.getOrDefault(
|
||||
name,
|
||||
List.of());
|
||||
if (existing.size() >= MAX_VALUES_PER_ATTRIBUTE) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
List<String> values = new ArrayList<>(existing);
|
||||
values.add(value);
|
||||
attributes.put(name, List.copyOf(values));
|
||||
if (attributes.size() > MAX_ATTRIBUTE_COUNT) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
}
|
||||
return Map.copyOf(attributes);
|
||||
}
|
||||
|
||||
private List<Element> childElements(
|
||||
Element parent,
|
||||
String localName) {
|
||||
List<Element> elements = new ArrayList<>();
|
||||
for (Node node = parent.getFirstChild();
|
||||
node != null;
|
||||
node = node.getNextSibling()) {
|
||||
if (node instanceof Element element
|
||||
&& (localName == null
|
||||
|| localName.equals(element.getLocalName())
|
||||
&& CAS_NAMESPACE.equals(
|
||||
element.getNamespaceURI()))) {
|
||||
elements.add(element);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
private String requiredText(JsonNode node) {
|
||||
if (!node.isTextual()
|
||||
|| node.textValue().isBlank()
|
||||
|| node.textValue().length()
|
||||
> MAX_ATTRIBUTE_VALUE_LENGTH) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return node.textValue();
|
||||
}
|
||||
|
||||
private String textValue(
|
||||
JsonNode node,
|
||||
String fallback) {
|
||||
return node.isTextual() && !node.textValue().isBlank()
|
||||
? node.textValue()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private ProviderAuthenticationFailureCode failureCode(
|
||||
String casCode) {
|
||||
if ("INVALID_TICKET".equals(casCode)) {
|
||||
return ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS;
|
||||
}
|
||||
if ("INVALID_SERVICE".equals(casCode)
|
||||
|| "INVALID_REQUEST".equals(casCode)) {
|
||||
return ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_MISCONFIGURED;
|
||||
}
|
||||
if ("UNAUTHORIZED_SERVICE".equals(casCode)) {
|
||||
return ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_ACCESS_DENIED;
|
||||
}
|
||||
return ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE;
|
||||
}
|
||||
|
||||
private URI appendPath(URI base, String path) {
|
||||
String value = base.toASCIIString();
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return URI.create(value + path);
|
||||
}
|
||||
|
||||
private ProviderAuthenticationException invalidResponse() {
|
||||
return new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CasValidationTransport {
|
||||
|
||||
CasValidationResponse exchange(
|
||||
URI uri,
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
int maximumResponseBytes);
|
||||
}
|
||||
|
||||
record CasValidationResponse(
|
||||
int statusCode,
|
||||
String body
|
||||
) {
|
||||
}
|
||||
|
||||
private static final class CasAuthenticationFailure
|
||||
extends RuntimeException {
|
||||
|
||||
private final String code;
|
||||
|
||||
private CasAuthenticationFailure(String code) {
|
||||
super("CAS_AUTHENTICATION_FAILURE");
|
||||
this.code = code == null || code.isBlank()
|
||||
? "UNKNOWN"
|
||||
: code;
|
||||
}
|
||||
|
||||
private String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CasTransportFailure
|
||||
extends RuntimeException {
|
||||
|
||||
private final ProviderAuthenticationFailureCode reasonCode;
|
||||
|
||||
private CasTransportFailure(
|
||||
ProviderAuthenticationFailureCode reasonCode) {
|
||||
super(reasonCode.name());
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
private ProviderAuthenticationFailureCode reasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class JdkCasValidationTransport {
|
||||
|
||||
private JdkCasValidationTransport() {
|
||||
}
|
||||
|
||||
private static CasValidationResponse exchange(
|
||||
URI uri,
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
int maximumResponseBytes) {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(connectTimeout)
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build();
|
||||
HttpRequest request = HttpRequest.newBuilder(uri)
|
||||
.timeout(readTimeout)
|
||||
.header("Accept", "application/json, application/xml")
|
||||
.GET()
|
||||
.build();
|
||||
CompletableFuture<HttpResponse<byte[]>> exchange =
|
||||
client.sendAsync(
|
||||
request,
|
||||
responseInfo ->
|
||||
new LimitedBodySubscriber(
|
||||
maximumResponseBytes));
|
||||
try {
|
||||
HttpResponse<byte[]> response = exchange.get(
|
||||
readTimeout.toNanos(),
|
||||
TimeUnit.NANOSECONDS);
|
||||
return new CasValidationResponse(
|
||||
response.statusCode(),
|
||||
new String(
|
||||
response.body(),
|
||||
StandardCharsets.UTF_8));
|
||||
} catch (TimeoutException exception) {
|
||||
exchange.cancel(true);
|
||||
throw new CasTransportFailure(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE);
|
||||
} catch (ExecutionException exception) {
|
||||
Throwable cause = exception.getCause();
|
||||
if (hasCause(
|
||||
cause,
|
||||
ResponseTooLargeException.class)) {
|
||||
throw new CasTransportFailure(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
if (hasTlsCause(cause)) {
|
||||
throw new CasTransportFailure(
|
||||
ProviderAuthenticationFailureCode
|
||||
.TLS_VALIDATION_FAILED);
|
||||
}
|
||||
throw new CasTransportFailure(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new CasTransportFailure(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasTlsCause(Throwable failure) {
|
||||
return hasCause(failure, SSLException.class);
|
||||
}
|
||||
|
||||
private static boolean hasCause(
|
||||
Throwable failure,
|
||||
Class<? extends Throwable> type) {
|
||||
for (Throwable current = failure;
|
||||
current != null;
|
||||
current = current.getCause()) {
|
||||
if (type.isInstance(current)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final class LimitedBodySubscriber
|
||||
implements HttpResponse.BodySubscriber<byte[]> {
|
||||
|
||||
private final int maximumResponseBytes;
|
||||
private final ByteArrayOutputStream output =
|
||||
new ByteArrayOutputStream();
|
||||
private final CompletableFuture<byte[]> body =
|
||||
new CompletableFuture<>();
|
||||
private Flow.Subscription subscription;
|
||||
|
||||
private LimitedBodySubscriber(
|
||||
int maximumResponseBytes) {
|
||||
this.maximumResponseBytes =
|
||||
maximumResponseBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<byte[]> getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSubscribe(
|
||||
Flow.Subscription nextSubscription) {
|
||||
if (subscription != null) {
|
||||
nextSubscription.cancel();
|
||||
return;
|
||||
}
|
||||
subscription = nextSubscription;
|
||||
nextSubscription.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(List<ByteBuffer> buffers) {
|
||||
if (body.isDone()) {
|
||||
return;
|
||||
}
|
||||
for (ByteBuffer buffer : buffers) {
|
||||
long nextSize = (long) output.size()
|
||||
+ buffer.remaining();
|
||||
if (nextSize > maximumResponseBytes) {
|
||||
subscription.cancel();
|
||||
body.completeExceptionally(
|
||||
new ResponseTooLargeException());
|
||||
return;
|
||||
}
|
||||
byte[] chunk =
|
||||
new byte[buffer.remaining()];
|
||||
buffer.get(chunk);
|
||||
output.writeBytes(chunk);
|
||||
}
|
||||
subscription.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable failure) {
|
||||
body.completeExceptionally(failure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
body.complete(output.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ResponseTooLargeException
|
||||
extends RuntimeException {
|
||||
|
||||
private ResponseTooLargeException() {
|
||||
super("CAS_RESPONSE_TOO_LARGE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StrictXmlErrorHandler
|
||||
implements ErrorHandler {
|
||||
|
||||
@Override
|
||||
public void warning(SAXParseException exception)
|
||||
throws SAXException {
|
||||
throw exception;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(SAXParseException exception)
|
||||
throws SAXException {
|
||||
throw exception;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fatalError(SAXParseException exception)
|
||||
throws SAXException {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
/**
|
||||
* CAS service-ticket validation endpoint and preferred response format.
|
||||
*/
|
||||
public enum CasProtocolVersion {
|
||||
V2_0("2.0", "/serviceValidate", false),
|
||||
V3_0("3.0", "/p3/serviceValidate", true);
|
||||
|
||||
private final String wireValue;
|
||||
private final String validationPath;
|
||||
private final boolean jsonPreferred;
|
||||
|
||||
CasProtocolVersion(
|
||||
String wireValue,
|
||||
String validationPath,
|
||||
boolean jsonPreferred) {
|
||||
this.wireValue = wireValue;
|
||||
this.validationPath = validationPath;
|
||||
this.jsonPreferred = jsonPreferred;
|
||||
}
|
||||
|
||||
public String wireValue() {
|
||||
return wireValue;
|
||||
}
|
||||
|
||||
String validationPath() {
|
||||
return validationPath;
|
||||
}
|
||||
|
||||
boolean jsonPreferred() {
|
||||
return jsonPreferred;
|
||||
}
|
||||
|
||||
static CasProtocolVersion parse(String value) {
|
||||
return switch (value) {
|
||||
case "2.0" -> V2_0;
|
||||
case "3.0" -> V3_0;
|
||||
default -> throw new IllegalArgumentException(
|
||||
"Unsupported CAS protocol version");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
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 CAS provider configuration.
|
||||
*/
|
||||
@Component
|
||||
final class CasProviderConfiguration {
|
||||
|
||||
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 Duration MAX_STATE_TTL =
|
||||
Duration.ofMinutes(15);
|
||||
|
||||
private final CasProperties properties;
|
||||
private final Environment environment;
|
||||
|
||||
CasProviderConfiguration(
|
||||
CasProperties properties,
|
||||
Environment environment) {
|
||||
this.properties = properties;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
boolean enabled() {
|
||||
return properties.isEnabled();
|
||||
}
|
||||
|
||||
String configuredProviderCode() {
|
||||
return properties.getProviderCode();
|
||||
}
|
||||
|
||||
ResolvedCasProvider 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);
|
||||
String subjectType = requireMatching(
|
||||
properties.getSubjectType(),
|
||||
SUBJECT_TYPE_PATTERN);
|
||||
URI serverUri = requireEndpoint(
|
||||
properties.getServerUrl(),
|
||||
false);
|
||||
URI serviceUri = requireEndpoint(
|
||||
properties.getServiceUrl(),
|
||||
true);
|
||||
requireCallbackPath(serviceUri, providerCode);
|
||||
|
||||
CasProtocolVersion protocolVersion;
|
||||
try {
|
||||
protocolVersion = CasProtocolVersion.parse(
|
||||
properties.getProtocolVersion());
|
||||
} catch (RuntimeException exception) {
|
||||
throw invalidConfiguration();
|
||||
}
|
||||
|
||||
CasProperties.Attributes attributes =
|
||||
Objects.requireNonNull(properties.getAttributes());
|
||||
Optional<String> subjectAttribute =
|
||||
optionalAttribute(attributes.getSubject());
|
||||
Optional<String> displayNameAttribute =
|
||||
optionalAttribute(attributes.getDisplayName());
|
||||
Optional<String> emailAttribute =
|
||||
optionalAttribute(attributes.getEmail());
|
||||
Optional<String> avatarAttribute =
|
||||
optionalAttribute(attributes.getAvatarUrl());
|
||||
|
||||
Duration connectTimeout = requireDuration(
|
||||
properties.getConnectTimeout(),
|
||||
MAX_NETWORK_TIMEOUT);
|
||||
Duration readTimeout = requireDuration(
|
||||
properties.getReadTimeout(),
|
||||
MAX_NETWORK_TIMEOUT);
|
||||
Duration stateTtl = requireDuration(
|
||||
properties.getStateTtl(),
|
||||
MAX_STATE_TTL);
|
||||
int maximumResponseBytes = properties.getMaxResponseBytes();
|
||||
if (maximumResponseBytes < 1024
|
||||
|| maximumResponseBytes > 1024 * 1024) {
|
||||
throw invalidConfiguration();
|
||||
}
|
||||
|
||||
return new ResolvedCasProvider(
|
||||
providerCode,
|
||||
displayName,
|
||||
authority,
|
||||
serverUri,
|
||||
serviceUri,
|
||||
protocolVersion,
|
||||
subjectType,
|
||||
subjectAttribute,
|
||||
displayNameAttribute,
|
||||
emailAttribute,
|
||||
avatarAttribute,
|
||||
connectTimeout,
|
||||
readTimeout,
|
||||
stateTtl,
|
||||
maximumResponseBytes);
|
||||
}
|
||||
|
||||
private URI requireEndpoint(
|
||||
String value,
|
||||
boolean service) {
|
||||
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.getFragment() != null
|
||||
|| uri.getQuery() != null
|
||||
|| uri.getPort() == 0
|
||||
|| uri.getPort() > 65535) {
|
||||
throw invalidConfiguration();
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if ("https".equalsIgnoreCase(scheme)) {
|
||||
return uri;
|
||||
}
|
||||
if (!"http".equalsIgnoreCase(scheme)
|
||||
|| !properties.isAllowInsecureForTesting()
|
||||
|| !environment.acceptsProfiles(Profiles.of(
|
||||
"local",
|
||||
"test",
|
||||
"staging"))) {
|
||||
throw invalidConfiguration();
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private void requireCallbackPath(
|
||||
URI serviceUri,
|
||||
String providerCode) {
|
||||
String expectedSuffix = "/api/v1/auth/cas/"
|
||||
+ providerCode
|
||||
+ "/callback";
|
||||
if (!serviceUri.getPath().endsWith(expectedSuffix)) {
|
||||
throw invalidConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
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 Duration requireDuration(
|
||||
Duration value,
|
||||
Duration maximum) {
|
||||
if (value == null
|
||||
|| value.isZero()
|
||||
|| value.isNegative()
|
||||
|| value.compareTo(maximum) > 0) {
|
||||
throw invalidConfiguration();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private IllegalArgumentException invalidConfiguration() {
|
||||
return new IllegalArgumentException(
|
||||
"Invalid CAS provider configuration");
|
||||
}
|
||||
|
||||
record ResolvedCasProvider(
|
||||
String providerCode,
|
||||
String displayName,
|
||||
String authority,
|
||||
URI serverUri,
|
||||
URI serviceUri,
|
||||
CasProtocolVersion protocolVersion,
|
||||
String subjectType,
|
||||
Optional<String> subjectAttribute,
|
||||
Optional<String> displayNameAttribute,
|
||||
Optional<String> emailAttribute,
|
||||
Optional<String> avatarAttribute,
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
Duration stateTtl,
|
||||
int maximumResponseBytes
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Standard CAS 2.0/3.0 protocol transport and identity-result adapter.
|
||||
*/
|
||||
package com.iflytek.skillhub.auth.cas;
|
||||
|
|
@ -19,8 +19,9 @@ import org.springframework.stereotype.Component;
|
|||
/**
|
||||
* Owns the raw, high-entropy session state used by Identity Link workflows.
|
||||
*
|
||||
* <p>Only a SHA-256 digest is stored outside the session. Raw nonces and OAuth
|
||||
* state values are never returned by API DTOs or written to the database.
|
||||
* <p>Only a SHA-256 digest is stored outside the session. Raw nonces and
|
||||
* browser-protocol state values are never returned by API DTOs or written to
|
||||
* the database.
|
||||
*/
|
||||
@Component
|
||||
public class IdentityLinkSessionManager {
|
||||
|
|
@ -132,14 +133,14 @@ public class IdentityLinkSessionManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Binds a prepared link flow to the OAuth authorization request generated
|
||||
* by Spring Security. The raw OAuth state remains in Spring Security's
|
||||
* authorization request repository; only its digest is copied here.
|
||||
* Binds a prepared link flow to the browser protocol's authorization
|
||||
* request. OAuth keeps the raw state in Spring Security and CAS keeps it
|
||||
* in the CAS state store; only its digest is copied here.
|
||||
*/
|
||||
public void activateBrowserFlow(
|
||||
HttpSession session,
|
||||
String providerCode,
|
||||
String oauthState) {
|
||||
String browserState) {
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -149,8 +150,8 @@ public class IdentityLinkSessionManager {
|
|||
if (!(value instanceof PendingBrowserFlow pending)
|
||||
|| pending.expiresAt().isBefore(now())
|
||||
|| !pending.providerCode().equals(providerCode)
|
||||
|| oauthState == null
|
||||
|| oauthState.isBlank()) {
|
||||
|| browserState == null
|
||||
|| browserState.isBlank()) {
|
||||
return;
|
||||
}
|
||||
session.setAttribute(
|
||||
|
|
@ -159,7 +160,7 @@ public class IdentityLinkSessionManager {
|
|||
pending.intentId(),
|
||||
pending.phase(),
|
||||
pending.providerCode(),
|
||||
stateHasher.hash(oauthState),
|
||||
stateHasher.hash(browserState),
|
||||
pending.expiresAt()));
|
||||
}
|
||||
|
||||
|
|
@ -182,7 +183,7 @@ public class IdentityLinkSessionManager {
|
|||
|| !active.providerCode().equals(providerCode)
|
||||
|| !stateHasher.matches(
|
||||
callbackState,
|
||||
active.oauthStateHash())) {
|
||||
active.browserStateHash())) {
|
||||
throw new IdentityLinkException(
|
||||
IdentityLinkFailureCode.SESSION_MISMATCH);
|
||||
}
|
||||
|
|
@ -277,7 +278,7 @@ public class IdentityLinkSessionManager {
|
|||
UUID intentId,
|
||||
IdentityLinkBrowserPhase phase,
|
||||
String providerCode,
|
||||
String oauthStateHash,
|
||||
String browserStateHash,
|
||||
Instant expiresAt
|
||||
) implements Serializable {
|
||||
@Serial
|
||||
|
|
|
|||
|
|
@ -496,12 +496,10 @@ class IdentityLinkTransaction {
|
|||
!linkedProviderCodes.contains(
|
||||
provider.providerCode()))
|
||||
.filter(provider ->
|
||||
provider.methodTypes().contains(
|
||||
IdentityProviderLoginMethodType
|
||||
.OAUTH_REDIRECT)
|
||||
|| provider.methodTypes().contains(
|
||||
IdentityProviderLoginMethodType
|
||||
.DIRECT_PASSWORD))
|
||||
provider.methodTypes().stream()
|
||||
.anyMatch(
|
||||
IdentityLinkTransaction
|
||||
::isLinkableMethod))
|
||||
.sorted(Comparator.comparing(
|
||||
ReadyProvider::providerCode))
|
||||
.map(provider ->
|
||||
|
|
@ -655,15 +653,25 @@ class IdentityLinkTransaction {
|
|||
}
|
||||
ReadyProvider provider = readyProviders().get(providerCode);
|
||||
if (provider == null
|
||||
|| (provider.methodTypes().stream().noneMatch(type ->
|
||||
type == IdentityProviderLoginMethodType.OAUTH_REDIRECT
|
||||
|| type == IdentityProviderLoginMethodType
|
||||
.DIRECT_PASSWORD))) {
|
||||
|| provider.methodTypes().stream()
|
||||
.noneMatch(
|
||||
IdentityLinkTransaction
|
||||
::isLinkableMethod)) {
|
||||
throw failure(
|
||||
IdentityLinkFailureCode.PROVIDER_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLinkableMethod(
|
||||
IdentityProviderLoginMethodType methodType) {
|
||||
return methodType
|
||||
== IdentityProviderLoginMethodType.OAUTH_REDIRECT
|
||||
|| methodType
|
||||
== IdentityProviderLoginMethodType.CAS_REDIRECT
|
||||
|| methodType
|
||||
== IdentityProviderLoginMethodType.DIRECT_PASSWORD;
|
||||
}
|
||||
|
||||
private void requireProviderCapability(
|
||||
IdentityLinkActor actor,
|
||||
IdentityLinkRequest request,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ public record IdentityProviderLoginMethod(
|
|||
|
||||
public ProviderCapability capability() {
|
||||
return switch (methodType) {
|
||||
case OAUTH_REDIRECT -> ProviderCapability.BROWSER;
|
||||
case OAUTH_REDIRECT, CAS_REDIRECT ->
|
||||
ProviderCapability.BROWSER;
|
||||
case DIRECT_PASSWORD -> ProviderCapability.CREDENTIAL;
|
||||
case SESSION_BOOTSTRAP -> ProviderCapability.PASSIVE;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package com.iflytek.skillhub.auth.identity;
|
|||
*/
|
||||
public enum IdentityProviderLoginMethodType {
|
||||
OAUTH_REDIRECT,
|
||||
CAS_REDIRECT,
|
||||
DIRECT_PASSWORD,
|
||||
SESSION_BOOTSTRAP
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
import java.util.List;
|
||||
|
|
@ -13,10 +14,38 @@ public interface IdentityProviderRegistry extends IdentityProviderCatalog {
|
|||
|
||||
List<IdentityProviderLoginMethod> listReadyLoginMethods();
|
||||
|
||||
<T> BrowserRoute<T> requireBrowserRoute(
|
||||
String providerCode,
|
||||
Class<T> exchangeType);
|
||||
|
||||
CredentialRoute requireCredentialRoute(String providerCode);
|
||||
|
||||
PassiveRoute requirePassiveRoute(String providerCode);
|
||||
|
||||
class BrowserRoute<T> {
|
||||
private final ResolvedProviderHandle provider;
|
||||
private final BrowserAuthenticationAdapter<T> adapter;
|
||||
|
||||
public BrowserRoute(
|
||||
ResolvedProviderHandle provider,
|
||||
BrowserAuthenticationAdapter<T> adapter) {
|
||||
this.provider = Objects.requireNonNull(
|
||||
provider,
|
||||
"provider");
|
||||
this.adapter = Objects.requireNonNull(
|
||||
adapter,
|
||||
"adapter");
|
||||
}
|
||||
|
||||
public ResolvedProviderHandle provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public BrowserAuthenticationAdapter<T> adapter() {
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
|
||||
class CredentialRoute {
|
||||
private final ResolvedProviderHandle provider;
|
||||
private final CredentialAuthenticationAdapter adapter;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationMethod;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderInstanceDefinition;
|
||||
|
|
@ -42,6 +44,7 @@ class ReconciledIdentityProviderCatalog
|
|||
private final ProviderAuthorityLockService authorityLockService;
|
||||
private final IdentityBindingPreflightService bindingPreflightService;
|
||||
private final IdentityProviderPolicyProperties policyProperties;
|
||||
private final List<BrowserAuthenticationAdapter<?>> browserAdapters;
|
||||
private final List<CredentialAuthenticationAdapter> credentialAdapters;
|
||||
private final List<PassiveAuthenticationAdapter> passiveAdapters;
|
||||
private final AtomicReference<RegistrySnapshot> snapshot =
|
||||
|
|
@ -52,12 +55,14 @@ class ReconciledIdentityProviderCatalog
|
|||
ProviderAuthorityLockService authorityLockService,
|
||||
IdentityBindingPreflightService bindingPreflightService,
|
||||
IdentityProviderPolicyProperties policyProperties,
|
||||
List<BrowserAuthenticationAdapter<?>> browserAdapters,
|
||||
List<CredentialAuthenticationAdapter> credentialAdapters,
|
||||
List<PassiveAuthenticationAdapter> passiveAdapters) {
|
||||
this.descriptorSource = descriptorSource;
|
||||
this.authorityLockService = authorityLockService;
|
||||
this.bindingPreflightService = bindingPreflightService;
|
||||
this.policyProperties = policyProperties;
|
||||
this.browserAdapters = List.copyOf(browserAdapters);
|
||||
this.credentialAdapters = List.copyOf(credentialAdapters);
|
||||
this.passiveAdapters = List.copyOf(passiveAdapters);
|
||||
}
|
||||
|
|
@ -97,7 +102,9 @@ class ReconciledIdentityProviderCatalog
|
|||
private RegistrySnapshot assembleSnapshot() {
|
||||
Map<String, ProviderDescriptor> descriptors =
|
||||
new LinkedHashMap<>();
|
||||
Set<String> browserProviders = new HashSet<>();
|
||||
Set<String> oauthBrowserProviders = new HashSet<>();
|
||||
Map<String, BrowserRegistration<?>> browsers =
|
||||
new LinkedHashMap<>();
|
||||
Map<String, CredentialRegistration> credentials =
|
||||
new LinkedHashMap<>();
|
||||
Map<String, PassiveRegistration> passives =
|
||||
|
|
@ -111,7 +118,7 @@ class ReconciledIdentityProviderCatalog
|
|||
descriptors,
|
||||
invalidProviders,
|
||||
descriptor);
|
||||
browserProviders.add(descriptor.providerCode());
|
||||
oauthBrowserProviders.add(descriptor.providerCode());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.error(
|
||||
|
|
@ -121,6 +128,37 @@ class ReconciledIdentityProviderCatalog
|
|||
exception.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
for (BrowserAuthenticationAdapter<?> adapter
|
||||
: browserAdapters) {
|
||||
try {
|
||||
ProviderInstanceDefinition definition =
|
||||
adapter.provider();
|
||||
if (!definition.enabled()) {
|
||||
continue;
|
||||
}
|
||||
ProviderDescriptor descriptor =
|
||||
descriptorFrom(definition);
|
||||
registerDescriptor(
|
||||
descriptors,
|
||||
invalidProviders,
|
||||
descriptor);
|
||||
BrowserRegistration<?> previous = browsers.putIfAbsent(
|
||||
descriptor.providerCode(),
|
||||
new BrowserRegistration<>(
|
||||
adapter,
|
||||
definition.displayName()));
|
||||
if (previous != null) {
|
||||
invalidProviders.add(descriptor.providerCode());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn(
|
||||
"Browser provider adapter is hidden because its trusted definition is invalid");
|
||||
log.debug(
|
||||
"Browser provider definition failure type: {}",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
for (CredentialAuthenticationAdapter adapter
|
||||
: credentialAdapters) {
|
||||
try {
|
||||
|
|
@ -184,7 +222,8 @@ class ReconciledIdentityProviderCatalog
|
|||
|
||||
for (String providerCode : invalidProviders) {
|
||||
descriptors.remove(providerCode);
|
||||
browserProviders.remove(providerCode);
|
||||
oauthBrowserProviders.remove(providerCode);
|
||||
browsers.remove(providerCode);
|
||||
credentials.remove(providerCode);
|
||||
passives.remove(providerCode);
|
||||
log.error(
|
||||
|
|
@ -194,7 +233,8 @@ class ReconciledIdentityProviderCatalog
|
|||
|
||||
return new RegistrySnapshot(
|
||||
Map.copyOf(descriptors),
|
||||
Set.copyOf(browserProviders),
|
||||
Set.copyOf(oauthBrowserProviders),
|
||||
Map.copyOf(browsers),
|
||||
Map.copyOf(credentials),
|
||||
Map.copyOf(passives));
|
||||
}
|
||||
|
|
@ -269,7 +309,7 @@ class ReconciledIdentityProviderCatalog
|
|||
return current.descriptors().values().stream()
|
||||
.sorted(Comparator.comparing(
|
||||
ProviderDescriptor::providerCode))
|
||||
.filter(descriptor -> current.browserProviders()
|
||||
.filter(descriptor -> current.oauthBrowserProviders()
|
||||
.contains(descriptor.providerCode()))
|
||||
.filter(this::isCurrentlyReady)
|
||||
.map(descriptor -> loginMethod(
|
||||
|
|
@ -294,12 +334,20 @@ class ReconciledIdentityProviderCatalog
|
|||
continue;
|
||||
}
|
||||
String providerCode = descriptor.providerCode();
|
||||
if (current.browserProviders().contains(providerCode)) {
|
||||
if (current.oauthBrowserProviders().contains(providerCode)) {
|
||||
methods.add(loginMethod(
|
||||
providerCode,
|
||||
descriptor.displayName(),
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT));
|
||||
}
|
||||
BrowserRegistration<?> browser =
|
||||
current.browsers().get(providerCode);
|
||||
if (browser != null) {
|
||||
methods.add(loginMethod(
|
||||
providerCode,
|
||||
browser.displayName(),
|
||||
methodType(browser.adapter().loginMethod())));
|
||||
}
|
||||
CredentialRegistration credential =
|
||||
current.credentials().get(providerCode);
|
||||
if (credential != null) {
|
||||
|
|
@ -320,6 +368,24 @@ class ReconciledIdentityProviderCatalog
|
|||
return List.copyOf(methods);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> BrowserRoute<T> requireBrowserRoute(
|
||||
String providerCode,
|
||||
Class<T> exchangeType) {
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
ProviderDescriptor descriptor =
|
||||
requireReadyDescriptor(current, providerCode);
|
||||
BrowserRegistration<?> registration =
|
||||
current.browsers().get(providerCode);
|
||||
if (registration == null
|
||||
|| !registration.adapter()
|
||||
.exchangeType()
|
||||
.equals(exchangeType)) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return browserRoute(descriptor, registration, exchangeType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CredentialRoute requireCredentialRoute(
|
||||
String providerCode) {
|
||||
|
|
@ -359,7 +425,7 @@ class ReconciledIdentityProviderCatalog
|
|||
String providerCode =
|
||||
descriptorSource.resolveBrowserProviderCode(registration);
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
if (!current.browserProviders().contains(providerCode)) {
|
||||
if (!current.oauthBrowserProviders().contains(providerCode)) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
ProviderDescriptor descriptor =
|
||||
|
|
@ -418,6 +484,32 @@ class ReconciledIdentityProviderCatalog
|
|||
methodType);
|
||||
}
|
||||
|
||||
private IdentityProviderLoginMethodType methodType(
|
||||
BrowserAuthenticationMethod method) {
|
||||
return switch (method) {
|
||||
case OAUTH_REDIRECT ->
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT;
|
||||
case CAS_REDIRECT ->
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT;
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> BrowserRoute<T> browserRoute(
|
||||
ProviderDescriptor descriptor,
|
||||
BrowserRegistration<?> registration,
|
||||
Class<T> exchangeType) {
|
||||
BrowserAuthenticationAdapter<?> adapter =
|
||||
registration.adapter();
|
||||
if (!adapter.exchangeType().equals(exchangeType)) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return new BrowserRoute<>(
|
||||
new DefaultResolvedProviderHandle(
|
||||
descriptor.providerCode()),
|
||||
(BrowserAuthenticationAdapter<T>) adapter);
|
||||
}
|
||||
|
||||
private boolean isCurrentlyReady(ProviderDescriptor descriptor) {
|
||||
try {
|
||||
authorityLockService.requirePinnedAuthority(descriptor);
|
||||
|
|
@ -436,6 +528,11 @@ class ReconciledIdentityProviderCatalog
|
|||
IdentityFailureCode.PROVIDER_DISABLED);
|
||||
}
|
||||
|
||||
private record BrowserRegistration<T>(
|
||||
BrowserAuthenticationAdapter<T> adapter,
|
||||
String displayName) {
|
||||
}
|
||||
|
||||
private record CredentialRegistration(
|
||||
CredentialAuthenticationAdapter adapter,
|
||||
String displayName) {
|
||||
|
|
@ -448,7 +545,8 @@ class ReconciledIdentityProviderCatalog
|
|||
|
||||
private record RegistrySnapshot(
|
||||
Map<String, ProviderDescriptor> descriptors,
|
||||
Set<String> browserProviders,
|
||||
Set<String> oauthBrowserProviders,
|
||||
Map<String, BrowserRegistration<?>> browsers,
|
||||
Map<String, CredentialRegistration> credentials,
|
||||
Map<String, PassiveRegistration> passives
|
||||
) {
|
||||
|
|
@ -457,6 +555,7 @@ class ReconciledIdentityProviderCatalog
|
|||
Map.of(),
|
||||
Set.of(),
|
||||
Map.of(),
|
||||
Map.of(),
|
||||
Map.of());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
/**
|
||||
* Strategy interface for converting provider-specific OAuth user payloads into normalized claims.
|
||||
*
|
||||
* <p>The Spring Security client registration remains the registered browser
|
||||
* provider. This extractor only maps its already verified exchange and is not
|
||||
* independently routable.</p>
|
||||
*/
|
||||
public interface OAuthClaimsExtractor
|
||||
extends BrowserAuthenticationAdapter<OAuthAuthenticationExchange> {
|
||||
public interface OAuthClaimsExtractor {
|
||||
String getProvider();
|
||||
ProviderAuthenticationResult extract(
|
||||
OAuth2UserRequest request,
|
||||
OAuth2User oAuth2User);
|
||||
|
||||
@Override
|
||||
default ProviderAuthenticationResult authenticate(
|
||||
OAuthAuthenticationExchange exchange) {
|
||||
return extract(exchange.request(), exchange.user());
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/me"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/session/bootstrap"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"),
|
||||
RouteAuthorizationPolicy.permitAll(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/auth/cas/*/login"),
|
||||
RouteAuthorizationPolicy.permitAll(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/auth/cas/*/callback"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
|||
*/
|
||||
public interface BrowserAuthenticationAdapter<T> {
|
||||
|
||||
ProviderInstanceDefinition provider();
|
||||
|
||||
Class<T> exchangeType();
|
||||
|
||||
BrowserAuthenticationMethod loginMethod();
|
||||
|
||||
/**
|
||||
* @throws ProviderAuthenticationException when the verified exchange
|
||||
* cannot be accepted or its upstream is unavailable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
/**
|
||||
* Core-recognized browser interaction used to project a fixed first-party
|
||||
* login route. Adapters do not provide arbitrary action URLs.
|
||||
*/
|
||||
public enum BrowserAuthenticationMethod {
|
||||
OAUTH_REDIRECT,
|
||||
CAS_REDIRECT
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.EmailAssurance;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderConformanceKit;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CasAuthenticationAdapterTest {
|
||||
|
||||
@Test
|
||||
void mapsVerifiedPrincipalAndOnlyConfiguredProfileAttributes()
|
||||
throws IOException {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.getAttributes().setDisplayName("cn");
|
||||
properties.getAttributes().setEmail("mail");
|
||||
CasAuthenticationAdapter adapter =
|
||||
new CasAuthenticationAdapter(
|
||||
CasTestConfiguration.configuration(properties));
|
||||
CasAuthenticationExchange exchange =
|
||||
new CasAuthenticationExchange(
|
||||
"alice-123",
|
||||
Map.of(
|
||||
"cn",
|
||||
List.of("Alice"),
|
||||
"mail",
|
||||
List.of("alice@example.com"),
|
||||
"unmapped",
|
||||
List.of("must-not-leak")),
|
||||
Instant.parse("2026-07-31T00:00:00Z"));
|
||||
|
||||
var result = ProviderConformanceKit.verifyBrowser(
|
||||
adapter,
|
||||
exchange);
|
||||
|
||||
assertThat(result.primarySubject().type())
|
||||
.isEqualTo("cas_principal");
|
||||
assertThat(result.primarySubject().value())
|
||||
.isEqualTo("alice-123");
|
||||
assertThat(result.attributes())
|
||||
.containsOnlyKeys(
|
||||
CasAuthenticationAdapter
|
||||
.DISPLAY_NAME_ATTRIBUTE,
|
||||
CasAuthenticationAdapter.EMAIL_ATTRIBUTE);
|
||||
assertThat(result.attributes()
|
||||
.get(CasAuthenticationAdapter.EMAIL_ATTRIBUTE)
|
||||
.getFirst()
|
||||
.trust()).isEqualTo(
|
||||
ProviderAttributeTrust.ASSERTED);
|
||||
assertThat(adapter.provider().emailAssuranceLimit())
|
||||
.isEqualTo(EmailAssurance.PROVIDER_ASSERTED);
|
||||
ProviderConformanceKit.verifyAdapterBoundary(
|
||||
CasAuthenticationAdapter.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitlyConfiguredImmutableAttributeBecomesSubject() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setSubjectType("cas_employee_id");
|
||||
properties.getAttributes().setSubject("employeeId");
|
||||
CasAuthenticationAdapter adapter =
|
||||
new CasAuthenticationAdapter(
|
||||
CasTestConfiguration.configuration(properties));
|
||||
|
||||
var result = adapter.authenticate(
|
||||
new CasAuthenticationExchange(
|
||||
"mutable-login",
|
||||
Map.of(
|
||||
"employeeId",
|
||||
List.of("employee-42")),
|
||||
Instant.parse("2026-07-31T00:00:00Z")));
|
||||
|
||||
assertThat(result.primarySubject().type())
|
||||
.isEqualTo("cas_employee_id");
|
||||
assertThat(result.primarySubject().value())
|
||||
.isEqualTo("employee-42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrAmbiguousConfiguredSubjectFailsClosed() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setSubjectType("cas_employee_id");
|
||||
properties.getAttributes().setSubject("employeeId");
|
||||
CasAuthenticationAdapter adapter =
|
||||
new CasAuthenticationAdapter(
|
||||
CasTestConfiguration.configuration(properties));
|
||||
|
||||
assertThatThrownBy(() -> adapter.authenticate(
|
||||
new CasAuthenticationExchange(
|
||||
"alice",
|
||||
Map.of(),
|
||||
Instant.parse("2026-07-31T00:00:00Z"))))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
|
||||
assertThatThrownBy(() -> adapter.authenticate(
|
||||
new CasAuthenticationExchange(
|
||||
"alice",
|
||||
Map.of(
|
||||
"employeeId",
|
||||
List.of("one", "two")),
|
||||
Instant.parse("2026-07-31T00:00:00Z"))))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
|
||||
assertThatThrownBy(() -> adapter.authenticate(
|
||||
new CasAuthenticationExchange(
|
||||
"alice",
|
||||
Map.of(
|
||||
"employeeId",
|
||||
List.of("x".repeat(4097))),
|
||||
Instant.parse("2026-07-31T00:00:00Z"))))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAdapterHasNoRoutableDefinition() {
|
||||
CasProperties properties = new CasProperties();
|
||||
CasAuthenticationAdapter adapter =
|
||||
new CasAuthenticationAdapter(
|
||||
CasTestConfiguration.configuration(properties));
|
||||
|
||||
assertThat(adapter.provider().enabled()).isFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class CasProtocolClientHttpIntegrationTest {
|
||||
|
||||
private static final String STATE =
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789_ABCD";
|
||||
|
||||
private HttpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(
|
||||
new InetSocketAddress(
|
||||
InetAddress.getLoopbackAddress(),
|
||||
0),
|
||||
0);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesWithTheRealTransportAndExactServiceQuery()
|
||||
throws IOException {
|
||||
AtomicReference<String> rawQuery = new AtomicReference<>();
|
||||
server.createContext(
|
||||
"/cas/p3/serviceValidate",
|
||||
exchange -> {
|
||||
rawQuery.set(exchange.getRequestURI().getRawQuery());
|
||||
respond(
|
||||
exchange,
|
||||
200,
|
||||
"""
|
||||
{
|
||||
"serviceResponse": {
|
||||
"authenticationSuccess": {
|
||||
"user": "alice"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
});
|
||||
|
||||
CasProtocolClient client = client(1024);
|
||||
String service = client.begin(
|
||||
"cas-main",
|
||||
STATE).serviceUrl();
|
||||
|
||||
CasAuthenticationExchange authentication = client.validate(
|
||||
"cas-main",
|
||||
"ST-real-transport",
|
||||
service);
|
||||
|
||||
assertThat(authentication.principal()).isEqualTo("alice");
|
||||
var query = UriComponentsBuilder
|
||||
.fromUriString("http://localhost/?" + rawQuery.get())
|
||||
.build()
|
||||
.getQueryParams();
|
||||
assertThat(query.getFirst("ticket"))
|
||||
.isEqualTo("ST-real-transport");
|
||||
assertThat(decode(query.getFirst("service")))
|
||||
.isEqualTo(service);
|
||||
assertThat(query.getFirst("format")).isEqualTo("JSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
void realTransportNeverFollowsRedirects() throws IOException {
|
||||
AtomicInteger redirectedRequests = new AtomicInteger();
|
||||
server.createContext(
|
||||
"/cas/p3/serviceValidate",
|
||||
exchange -> {
|
||||
exchange.getResponseHeaders().add(
|
||||
"Location",
|
||||
"/cas/redirected");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.createContext(
|
||||
"/cas/redirected",
|
||||
exchange -> {
|
||||
redirectedRequests.incrementAndGet();
|
||||
respond(
|
||||
exchange,
|
||||
200,
|
||||
"""
|
||||
{
|
||||
"serviceResponse": {
|
||||
"authenticationSuccess": {
|
||||
"user": "redirected"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
});
|
||||
|
||||
CasProtocolClient client = client(1024);
|
||||
|
||||
assertThatThrownBy(() -> client.validate(
|
||||
"cas-main",
|
||||
"ST-no-redirect",
|
||||
client.begin("cas-main", STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
assertThat(redirectedRequests).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realTransportRejectsOversizedResponses() {
|
||||
server.createContext(
|
||||
"/cas/p3/serviceValidate",
|
||||
exchange -> respond(
|
||||
exchange,
|
||||
200,
|
||||
"x".repeat(1025)));
|
||||
|
||||
CasProtocolClient client = client(1024);
|
||||
|
||||
assertThatThrownBy(() -> client.validate(
|
||||
"cas-main",
|
||||
"ST-oversized",
|
||||
client.begin("cas-main", STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestTimeoutIncludesSlowResponseBodyConsumption() {
|
||||
server.createContext(
|
||||
"/cas/p3/serviceValidate",
|
||||
exchange -> {
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
try (var body = exchange.getResponseBody()) {
|
||||
body.write('{');
|
||||
body.flush();
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
body.write('}');
|
||||
}
|
||||
});
|
||||
|
||||
CasProtocolClient client = client(
|
||||
1024,
|
||||
Duration.ofMillis(100));
|
||||
|
||||
assertThatThrownBy(() -> client.validate(
|
||||
"cas-main",
|
||||
"ST-slow-body",
|
||||
client.begin("cas-main", STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private CasProtocolClient client(int maximumResponseBytes) {
|
||||
return client(
|
||||
maximumResponseBytes,
|
||||
Duration.ofSeconds(10));
|
||||
}
|
||||
|
||||
private CasProtocolClient client(
|
||||
int maximumResponseBytes,
|
||||
Duration readTimeout) {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setAllowInsecureForTesting(true);
|
||||
properties.setServerUrl(
|
||||
"http://127.0.0.1:"
|
||||
+ server.getAddress().getPort()
|
||||
+ "/cas");
|
||||
properties.setServiceUrl(
|
||||
"http://skillhub.test/api/v1/auth/cas/cas-main/callback");
|
||||
properties.setMaxResponseBytes(maximumResponseBytes);
|
||||
properties.setReadTimeout(readTimeout);
|
||||
MockEnvironment environment = new MockEnvironment();
|
||||
environment.setActiveProfiles("test");
|
||||
return new CasProtocolClient(
|
||||
new CasProviderConfiguration(
|
||||
properties,
|
||||
environment),
|
||||
new ObjectMapper());
|
||||
}
|
||||
|
||||
private void respond(
|
||||
HttpExchange exchange,
|
||||
int status,
|
||||
String response) throws IOException {
|
||||
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (var body = exchange.getResponseBody()) {
|
||||
body.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private String decode(String value) {
|
||||
return URLDecoder.decode(
|
||||
value,
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class CasProtocolClientTest {
|
||||
|
||||
private static final String STATE =
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789_ABCD";
|
||||
private static final Instant AUTHENTICATED_AT =
|
||||
Instant.parse("2026-07-31T00:00:00Z");
|
||||
|
||||
@Test
|
||||
void beginsWithCoreGeneratedLoginRouteAndExactStateBoundService() {
|
||||
CasProtocolClient client = client((uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
500,
|
||||
""));
|
||||
|
||||
CasLoginInitiation initiation =
|
||||
client.begin("cas-main", STATE);
|
||||
|
||||
assertThat(initiation.serviceUrl()).isEqualTo(
|
||||
"https://skillhub.example.com/api/v1/auth/cas/cas-main/callback?state="
|
||||
+ STATE);
|
||||
assertThat(initiation.loginUri().getPath())
|
||||
.isEqualTo("/cas/login");
|
||||
assertThat(UriComponentsBuilder
|
||||
.fromUri(initiation.loginUri())
|
||||
.build()
|
||||
.getQueryParams()
|
||||
.getFirst("service"))
|
||||
.satisfies(value -> assertThat(decode(value))
|
||||
.isEqualTo(initiation.serviceUrl()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesCas30JsonAgainstTheExactServiceUrl() {
|
||||
AtomicReference<URI> requested = new AtomicReference<>();
|
||||
CasProtocolClient client = client((uri, connect, read, maximum) -> {
|
||||
requested.set(uri);
|
||||
return new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
{
|
||||
"serviceResponse": {
|
||||
"authenticationSuccess": {
|
||||
"user": "alice",
|
||||
"attributes": {
|
||||
"cn": "Alice",
|
||||
"memberOf": ["engineering", "reviewers"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
});
|
||||
String service = client.begin(
|
||||
"cas-main",
|
||||
STATE).serviceUrl();
|
||||
|
||||
CasAuthenticationExchange exchange = client.validate(
|
||||
"cas-main",
|
||||
"ST-123",
|
||||
service);
|
||||
|
||||
assertThat(exchange.principal()).isEqualTo("alice");
|
||||
assertThat(exchange.attributes().get("memberOf"))
|
||||
.containsExactly("engineering", "reviewers");
|
||||
assertThat(exchange.authenticatedAt())
|
||||
.isEqualTo(AUTHENTICATED_AT);
|
||||
var query = UriComponentsBuilder
|
||||
.fromUri(requested.get())
|
||||
.build()
|
||||
.getQueryParams();
|
||||
assertThat(query.getFirst("ticket")).isEqualTo("ST-123");
|
||||
assertThat(decode(query.getFirst("service")))
|
||||
.isEqualTo(service);
|
||||
assertThat(query.getFirst("format")).isEqualTo("JSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesCas20XmlAndPreservesRepeatedAttributes() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setProtocolVersion("2.0");
|
||||
CasProtocolClient client = client(
|
||||
properties,
|
||||
(uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>alice</cas:user>
|
||||
<cas:attributes>
|
||||
<cas:memberOf>engineering</cas:memberOf>
|
||||
<cas:memberOf>reviewers</cas:memberOf>
|
||||
</cas:attributes>
|
||||
</cas:authenticationSuccess>
|
||||
</cas:serviceResponse>
|
||||
"""));
|
||||
|
||||
CasAuthenticationExchange exchange = client.validate(
|
||||
"cas-main",
|
||||
"ST-20",
|
||||
client.begin("cas-main", STATE).serviceUrl());
|
||||
|
||||
assertThat(exchange.attributes().get("memberOf"))
|
||||
.containsExactly("engineering", "reviewers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cas30AcceptsStandardsCompatibleXmlFallback() {
|
||||
CasProtocolClient client = client((uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>alice</cas:user>
|
||||
</cas:authenticationSuccess>
|
||||
</cas:serviceResponse>
|
||||
"""));
|
||||
|
||||
assertThat(client.validate(
|
||||
"cas-main",
|
||||
"ST-xml",
|
||||
client.begin("cas-main", STATE).serviceUrl())
|
||||
.principal()).isEqualTo("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidStateBoundServiceNeverReachesTheCasServer() {
|
||||
AtomicReference<URI> requested = new AtomicReference<>();
|
||||
CasProtocolClient client = client((uri, connect, read, maximum) -> {
|
||||
requested.set(uri);
|
||||
return new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"");
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> client.validate(
|
||||
"cas-main",
|
||||
"ST-123",
|
||||
"https://evil.example/callback?state=" + STATE))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
assertThat(requested.get()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesCasFailuresWithoutLeakingTicket() {
|
||||
CasProtocolClient client = client((uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
{
|
||||
"serviceResponse": {
|
||||
"authenticationFailure": {
|
||||
"code": "INVALID_TICKET",
|
||||
"description": "ticket rejected"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""));
|
||||
|
||||
assertThatThrownBy(() -> client.validate(
|
||||
"cas-main",
|
||||
"ST-secret-value",
|
||||
client.begin("cas-main", STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.hasMessage(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS
|
||||
.name())
|
||||
.hasMessageNotContaining("ST-secret-value")
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsXxeBeforeIdentityMapping() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setProtocolVersion("2.0");
|
||||
CasProtocolClient client = client(
|
||||
properties,
|
||||
(uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE serviceResponse [
|
||||
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||
]>
|
||||
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>&xxe;</cas:user>
|
||||
</cas:authenticationSuccess>
|
||||
</cas:serviceResponse>
|
||||
"""));
|
||||
|
||||
assertThatThrownBy(() -> client.validate(
|
||||
"cas-main",
|
||||
"ST-xxe",
|
||||
client.begin("cas-main", STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.hasMessageNotContaining("root:")
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongXmlNamespaceAndOversizedPrincipal() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setProtocolVersion("2.0");
|
||||
CasProtocolClient wrongNamespace = client(
|
||||
properties,
|
||||
(uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
<serviceResponse xmlns="https://evil.example/cas">
|
||||
<authenticationSuccess>
|
||||
<user>alice</user>
|
||||
</authenticationSuccess>
|
||||
</serviceResponse>
|
||||
"""));
|
||||
|
||||
assertThatThrownBy(() -> wrongNamespace.validate(
|
||||
"cas-main",
|
||||
"ST-wrong-namespace",
|
||||
wrongNamespace.begin(
|
||||
"cas-main",
|
||||
STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
|
||||
CasProtocolClient oversizedPrincipal = client(
|
||||
(uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"""
|
||||
{
|
||||
"serviceResponse": {
|
||||
"authenticationSuccess": {
|
||||
"user": "%s"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".formatted("x".repeat(4097))));
|
||||
|
||||
assertThatThrownBy(() -> oversizedPrincipal.validate(
|
||||
"cas-main",
|
||||
"ST-oversized-principal",
|
||||
oversizedPrincipal.begin(
|
||||
"cas-main",
|
||||
STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsHttpAndMalformedResponseFailures() {
|
||||
CasProtocolClient unavailable = client(
|
||||
(uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
503,
|
||||
""));
|
||||
String service = unavailable.begin(
|
||||
"cas-main",
|
||||
STATE).serviceUrl();
|
||||
|
||||
assertThatThrownBy(() -> unavailable.validate(
|
||||
"cas-main",
|
||||
"ST-503",
|
||||
service))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE);
|
||||
|
||||
CasProtocolClient malformed = client(
|
||||
(uri, connect, read, maximum) ->
|
||||
new CasProtocolClient.CasValidationResponse(
|
||||
200,
|
||||
"{\"unexpected\":true}"));
|
||||
|
||||
assertThatThrownBy(() -> malformed.validate(
|
||||
"cas-main",
|
||||
"ST-malformed",
|
||||
malformed.begin(
|
||||
"cas-main",
|
||||
STATE).serviceUrl()))
|
||||
.isInstanceOf(ProviderAuthenticationException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
private CasProtocolClient client(
|
||||
CasProtocolClient.CasValidationTransport transport) {
|
||||
return client(
|
||||
CasTestConfiguration.validProperties(),
|
||||
transport);
|
||||
}
|
||||
|
||||
private CasProtocolClient client(
|
||||
CasProperties properties,
|
||||
CasProtocolClient.CasValidationTransport transport) {
|
||||
return new CasProtocolClient(
|
||||
CasTestConfiguration.configuration(properties),
|
||||
new ObjectMapper(),
|
||||
transport,
|
||||
Clock.fixed(
|
||||
AUTHENTICATED_AT,
|
||||
ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private String decode(String value) {
|
||||
return URLDecoder.decode(
|
||||
value,
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
class CasProviderConfigurationTest {
|
||||
|
||||
@Test
|
||||
void resolvesStableAuthorityAndProtocolEndpoints() {
|
||||
CasProviderConfiguration.ResolvedCasProvider resolved =
|
||||
CasTestConfiguration.configuration(
|
||||
CasTestConfiguration.validProperties())
|
||||
.requireResolved();
|
||||
|
||||
assertThat(resolved.providerCode()).isEqualTo("cas-main");
|
||||
assertThat(resolved.authority()).isEqualTo("corp-cas");
|
||||
assertThat(resolved.serverUri().toString())
|
||||
.isEqualTo("https://cas.example.com/cas");
|
||||
assertThat(resolved.protocolVersion())
|
||||
.isEqualTo(CasProtocolVersion.V3_0);
|
||||
assertThat(resolved.subjectType())
|
||||
.isEqualTo("cas_principal");
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionCannotEnablePlainHttpEscapeHatch() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setServerUrl("http://cas.example.com/cas");
|
||||
properties.setServiceUrl(
|
||||
"http://skillhub.example.com/api/v1/auth/cas/cas-main/callback");
|
||||
properties.setAllowInsecureForTesting(true);
|
||||
CasProviderConfiguration configuration =
|
||||
new CasProviderConfiguration(
|
||||
properties,
|
||||
new MockEnvironment().withProperty(
|
||||
"spring.profiles.active",
|
||||
"prod"));
|
||||
|
||||
assertThatThrownBy(configuration::requireResolved)
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Invalid CAS provider configuration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stagingCanExplicitlyUseHttpForAnIsolatedFixture() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setServerUrl("http://127.0.0.1:18080/cas");
|
||||
properties.setServiceUrl(
|
||||
"http://127.0.0.1:18081/api/v1/auth/cas/cas-main/callback");
|
||||
properties.setAllowInsecureForTesting(true);
|
||||
CasProviderConfiguration configuration =
|
||||
new CasProviderConfiguration(
|
||||
properties,
|
||||
new MockEnvironment().withProperty(
|
||||
"spring.profiles.active",
|
||||
"staging"));
|
||||
|
||||
assertThat(configuration.requireResolved().serverUri()
|
||||
.getScheme()).isEqualTo("http");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnstableAuthorityAndWrongCallbackPath() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setAuthority("Corporate CAS");
|
||||
|
||||
assertThatThrownBy(() -> CasTestConfiguration
|
||||
.configuration(properties)
|
||||
.requireResolved())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
properties.setAuthority("corp-cas");
|
||||
properties.setServiceUrl(
|
||||
"https://skillhub.example.com/api/v1/auth/cas/callback");
|
||||
|
||||
assertThatThrownBy(() -> CasTestConfiguration
|
||||
.configuration(properties)
|
||||
.requireResolved())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsQueryFragmentsAndInvalidExplicitPorts() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setServerUrl(
|
||||
"https://cas.example.com/cas?tenant=corp");
|
||||
|
||||
assertThatThrownBy(() -> CasTestConfiguration
|
||||
.configuration(properties)
|
||||
.requireResolved())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
properties.setServerUrl("https://cas.example.com:0/cas");
|
||||
|
||||
assertThatThrownBy(() -> CasTestConfiguration
|
||||
.configuration(properties)
|
||||
.requireResolved())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnboundedTimeoutsAndPayloads() {
|
||||
CasProperties properties =
|
||||
CasTestConfiguration.validProperties();
|
||||
properties.setReadTimeout(Duration.ofMinutes(2));
|
||||
|
||||
assertThatThrownBy(() -> CasTestConfiguration
|
||||
.configuration(properties)
|
||||
.requireResolved())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
properties.setReadTimeout(Duration.ofSeconds(10));
|
||||
properties.setMaxResponseBytes(1024 * 1024 + 1);
|
||||
|
||||
assertThatThrownBy(() -> CasTestConfiguration
|
||||
.configuration(properties)
|
||||
.requireResolved())
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.iflytek.skillhub.auth.cas;
|
||||
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
final class CasTestConfiguration {
|
||||
|
||||
private CasTestConfiguration() {
|
||||
}
|
||||
|
||||
static CasProperties validProperties() {
|
||||
CasProperties properties = new CasProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setProviderCode("cas-main");
|
||||
properties.setDisplayName("Corporate CAS");
|
||||
properties.setAuthority("corp-cas");
|
||||
properties.setServerUrl("https://cas.example.com/cas");
|
||||
properties.setServiceUrl(
|
||||
"https://skillhub.example.com/api/v1/auth/cas/cas-main/callback");
|
||||
properties.setProtocolVersion("3.0");
|
||||
return properties;
|
||||
}
|
||||
|
||||
static CasProviderConfiguration configuration(
|
||||
CasProperties properties) {
|
||||
return new CasProviderConfiguration(
|
||||
properties,
|
||||
new MockEnvironment().withProperty(
|
||||
"spring.profiles.active",
|
||||
"test"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.IdentityBindingStatus;
|
||||
import com.iflytek.skillhub.auth.entity.IdentityLinkRequest;
|
||||
import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus;
|
||||
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityLinkRequestRepository;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IdentityLinkTransactionCasCapabilityTest {
|
||||
|
||||
private final IdentityLinkRequestRepository requestRepository =
|
||||
mock(IdentityLinkRequestRepository.class);
|
||||
private final IdentityBindingRepository bindingRepository =
|
||||
mock(IdentityBindingRepository.class);
|
||||
private final IdentityBindingSubjectRepository subjectRepository =
|
||||
mock(IdentityBindingSubjectRepository.class);
|
||||
private final LocalCredentialRepository credentialRepository =
|
||||
mock(LocalCredentialRepository.class);
|
||||
private final UserAccountRepository userRepository =
|
||||
mock(UserAccountRepository.class);
|
||||
private final IdentityProviderRegistry providerRegistry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
private final IdentityLinkStateHasher stateHasher =
|
||||
new IdentityLinkStateHasher();
|
||||
private final AccountLoginGuard accountLoginGuard =
|
||||
mock(AccountLoginGuard.class);
|
||||
private final PlatformPrincipalFactory principalFactory =
|
||||
mock(PlatformPrincipalFactory.class);
|
||||
private final AuditLogService auditLogService =
|
||||
mock(AuditLogService.class);
|
||||
private final UserAccount user = mock(UserAccount.class);
|
||||
private final IdentityLinkTransaction transaction =
|
||||
new IdentityLinkTransaction(
|
||||
requestRepository,
|
||||
bindingRepository,
|
||||
subjectRepository,
|
||||
credentialRepository,
|
||||
userRepository,
|
||||
providerRegistry,
|
||||
stateHasher,
|
||||
accountLoginGuard,
|
||||
principalFactory,
|
||||
auditLogService,
|
||||
Clock.fixed(
|
||||
Instant.parse("2026-07-31T08:00:00Z"),
|
||||
ZoneOffset.UTC));
|
||||
|
||||
@BeforeEach
|
||||
void configureReadyCasProvider() {
|
||||
when(providerRegistry.listReadyLoginMethods())
|
||||
.thenReturn(List.of(new IdentityProviderLoginMethod(
|
||||
"cas-main",
|
||||
"Corporate CAS",
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT)));
|
||||
when(accountLoginGuard.evaluateInteractive(user))
|
||||
.thenReturn(AccountLoginDecision.ALLOWED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesReadyCasAsAnAvailableIdentityLinkProvider() {
|
||||
when(userRepository.findById("usr_1"))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(bindingRepository.findByUserIdAndStatus(
|
||||
"usr_1",
|
||||
IdentityBindingStatus.ACTIVE))
|
||||
.thenReturn(List.of());
|
||||
when(credentialRepository.existsByUserId("usr_1"))
|
||||
.thenReturn(true);
|
||||
|
||||
IdentityLinkAccountState state =
|
||||
transaction.accountState("usr_1");
|
||||
|
||||
assertThat(state.availableProviders())
|
||||
.containsExactly(new IdentityLinkProviderView(
|
||||
"cas-main",
|
||||
"Corporate CAS",
|
||||
Set.of(
|
||||
IdentityProviderLoginMethodType
|
||||
.CAS_REDIRECT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsLinkIntentForReadyCasProvider() {
|
||||
UUID intentId = UUID.fromString(
|
||||
"94a94b82-bdb6-46bf-a1ff-910514519308");
|
||||
IdentityLinkActor actor = new IdentityLinkActor(
|
||||
"usr_1",
|
||||
"local",
|
||||
"session-nonce",
|
||||
new IdentityLoginContext(
|
||||
"req-1",
|
||||
"127.0.0.1",
|
||||
"JUnit"));
|
||||
when(userRepository.findByIdForUpdate("usr_1"))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(bindingRepository.findByUserIdAndStatus(
|
||||
"usr_1",
|
||||
IdentityBindingStatus.ACTIVE))
|
||||
.thenReturn(List.of());
|
||||
when(requestRepository.findActiveByPrimaryUserIdForUpdate(
|
||||
any(),
|
||||
any())).thenReturn(Optional.empty());
|
||||
when(requestRepository.saveAndFlush(
|
||||
any(IdentityLinkRequest.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
IdentityLinkIntent intent = transaction.createLinkIntent(
|
||||
actor,
|
||||
intentId,
|
||||
"cas-main");
|
||||
|
||||
assertThat(intent.id()).isEqualTo(intentId);
|
||||
assertThat(intent.providerCode()).isEqualTo("cas-main");
|
||||
assertThat(intent.status()).isEqualTo(
|
||||
IdentityLinkRequestStatus
|
||||
.PENDING_REAUTHENTICATION);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import static org.mockito.Mockito.times;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationMethod;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
|
|
@ -49,6 +51,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of());
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +176,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(),
|
||||
List.of(credential),
|
||||
List.of(passive));
|
||||
|
||||
|
|
@ -223,6 +227,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(),
|
||||
List.of(adapter),
|
||||
List.of());
|
||||
|
||||
|
|
@ -256,6 +261,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(),
|
||||
List.of(credential),
|
||||
List.of(passive));
|
||||
|
||||
|
|
@ -281,6 +287,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(),
|
||||
List.of(adapter),
|
||||
List.of());
|
||||
|
||||
|
|
@ -290,6 +297,76 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
verify(adapter, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersAndRoutesReadyCasBrowserAdapterByExchangeType() {
|
||||
BrowserAuthenticationAdapter<String> adapter =
|
||||
new BrowserAuthenticationAdapter<>() {
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return new ProviderInstanceDefinition(
|
||||
"cas-main",
|
||||
"cas",
|
||||
"corp-cas",
|
||||
"Corporate CAS",
|
||||
"cas_principal",
|
||||
"cas_principal",
|
||||
Map.of(
|
||||
"cas_principal",
|
||||
SubjectNormalization.EXACT),
|
||||
List.of("display_name"),
|
||||
List.of("email"),
|
||||
List.of(),
|
||||
EmailAssurance.PROVIDER_ASSERTED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<String> exchangeType() {
|
||||
return String.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BrowserAuthenticationMethod loginMethod() {
|
||||
return BrowserAuthenticationMethod.CAS_REDIRECT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAuthenticationResult authenticate(
|
||||
String exchange) {
|
||||
throw new UnsupportedOperationException(
|
||||
"not called");
|
||||
}
|
||||
};
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of());
|
||||
when(authorityLockService.isReady(any()))
|
||||
.thenReturn(true);
|
||||
catalog = new ReconciledIdentityProviderCatalog(
|
||||
descriptorSource,
|
||||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(adapter),
|
||||
List.of(),
|
||||
List.of());
|
||||
|
||||
catalog.reconcile();
|
||||
|
||||
assertThat(catalog.listReadyLoginMethods())
|
||||
.containsExactly(new IdentityProviderLoginMethod(
|
||||
"cas-main",
|
||||
"Corporate CAS",
|
||||
IdentityProviderLoginMethodType.CAS_REDIRECT));
|
||||
assertThat(catalog.requireBrowserRoute(
|
||||
"cas-main",
|
||||
String.class).adapter()).isSameAs(adapter);
|
||||
assertThatThrownBy(() -> catalog.requireBrowserRoute(
|
||||
"cas-main",
|
||||
Integer.class))
|
||||
.isInstanceOf(IdentityCoreException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
}
|
||||
|
||||
private static ProviderDescriptor descriptor(
|
||||
String providerCode,
|
||||
String displayName) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,33 @@ class RouteSecurityPolicyRegistryTest {
|
|||
assertTrue(matchedWeb);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationPolicies_shouldAllowOnlyCasBrowserEndpointsAnonymously() {
|
||||
boolean loginMatched = registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy -> policy.method() == HttpMethod.GET
|
||||
&& "/api/v1/auth/cas/*/login".equals(
|
||||
policy.pattern())
|
||||
&& policy.accessLevel()
|
||||
== RouteSecurityPolicyRegistry
|
||||
.AccessLevel.PERMIT_ALL);
|
||||
boolean callbackMatched = registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy -> policy.method() == HttpMethod.GET
|
||||
&& "/api/v1/auth/cas/*/callback".equals(
|
||||
policy.pattern())
|
||||
&& policy.accessLevel()
|
||||
== RouteSecurityPolicyRegistry
|
||||
.AccessLevel.PERMIT_ALL);
|
||||
boolean broadWildcardPresent =
|
||||
registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy ->
|
||||
"/api/v1/auth/cas/**".equals(
|
||||
policy.pattern()));
|
||||
|
||||
assertTrue(loginMatched);
|
||||
assertTrue(callbackMatched);
|
||||
assertFalse(broadWildcardPresent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationPolicies_shouldRequireAuthenticationForNamespaceDiscovery() {
|
||||
boolean matchedV1 = registry.authorizationPolicies().stream()
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ public final class ProviderConformanceKit {
|
|||
}
|
||||
|
||||
public static <T> ProviderAuthenticationResult verifyBrowser(
|
||||
ProviderInstanceDefinition provider,
|
||||
BrowserAuthenticationAdapter<T> adapter,
|
||||
T fixture) {
|
||||
assertThat(provider).isNotNull();
|
||||
ProviderInstanceDefinition provider =
|
||||
verifyDefinition(adapter.provider(), adapter.provider());
|
||||
ProviderAuthenticationResult result =
|
||||
adapter.authenticate(fixture);
|
||||
verifyResult(provider, result);
|
||||
|
|
|
|||
|
|
@ -84,7 +84,28 @@ class ProviderConformanceKitTest {
|
|||
"X-Private-Assertion",
|
||||
List.of("fixture-assertion")));
|
||||
BrowserAuthenticationAdapter<String> browser =
|
||||
exchange -> result;
|
||||
new BrowserAuthenticationAdapter<>() {
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<String> exchangeType() {
|
||||
return String.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BrowserAuthenticationMethod loginMethod() {
|
||||
return BrowserAuthenticationMethod.OAUTH_REDIRECT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAuthenticationResult authenticate(
|
||||
String exchange) {
|
||||
return result;
|
||||
}
|
||||
};
|
||||
PassiveAuthenticationAdapter passive =
|
||||
new PassiveAuthenticationAdapter() {
|
||||
@Override
|
||||
|
|
@ -101,7 +122,6 @@ class ProviderConformanceKitTest {
|
|||
};
|
||||
|
||||
assertThat(ProviderConformanceKit.verifyBrowser(
|
||||
provider,
|
||||
browser,
|
||||
"verified-exchange")).isSameAs(result);
|
||||
assertThat(ProviderConformanceKit.verifyPassive(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
log_format skillhub_no_query
|
||||
'$remote_addr - $remote_user [$time_local] '
|
||||
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
|
||||
'"-" "$http_user_agent"';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
access_log /var/log/nginx/access.log skillhub_no_query;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
|
|
|||
83
web/src/api/generated/schema.d.ts
vendored
83
web/src/api/generated/schema.d.ts
vendored
|
|
@ -3238,6 +3238,40 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/cas/{providerCode}/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Start a CAS browser login */
|
||||
get: operations["login_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/cas/{providerCode}/callback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Complete a CAS browser login */
|
||||
get: operations["callback"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5145,14 +5179,14 @@ export interface components {
|
|||
bindingId?: number;
|
||||
providerCode?: string;
|
||||
displayName?: string;
|
||||
methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[];
|
||||
methodTypes?: ("OAUTH_REDIRECT" | "CAS_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[];
|
||||
usable?: boolean;
|
||||
canUnlink?: boolean;
|
||||
};
|
||||
IdentityLinkProviderResponse: {
|
||||
providerCode?: string;
|
||||
displayName?: string;
|
||||
methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[];
|
||||
methodTypes?: ("OAUTH_REDIRECT" | "CAS_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[];
|
||||
};
|
||||
AdminUserSummaryResponse: {
|
||||
id?: string;
|
||||
|
|
@ -11845,6 +11879,51 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
login_1: {
|
||||
parameters: {
|
||||
query?: {
|
||||
returnTo?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
providerCode: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Redirect to the configured CAS login endpoint */
|
||||
302: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
callback: {
|
||||
parameters: {
|
||||
query?: {
|
||||
ticket?: string;
|
||||
state?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
providerCode: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Redirect to the authenticated target or login failure page */
|
||||
302: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listUsers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,14 @@ export type OAuthProvider = Omit<components['schemas']['AuthProviderResponse'],
|
|||
authorizationUrl: string
|
||||
}
|
||||
|
||||
export interface AuthMethod {
|
||||
type AuthMethodSchema = components['schemas']['AuthMethodResponse']
|
||||
|
||||
export type AuthMethod = Omit<
|
||||
AuthMethodSchema,
|
||||
'id' | 'methodType' | 'provider' | 'displayName' | 'actionUrl'
|
||||
> & {
|
||||
id: string
|
||||
methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string
|
||||
methodType: NonNullable<AuthMethodSchema['methodType']>
|
||||
provider: string
|
||||
displayName: string
|
||||
actionUrl: string
|
||||
|
|
|
|||
|
|
@ -163,6 +163,33 @@ describe('IdentityLinkManager', () => {
|
|||
expect(html).toMatch(/<button[^>]*disabled/)
|
||||
})
|
||||
|
||||
it('treats CAS as a browser identity link method', () => {
|
||||
accountState = {
|
||||
localPasswordEnabled: false,
|
||||
linkedProviders: [{
|
||||
bindingId: 43,
|
||||
providerCode: 'cas-main',
|
||||
displayName: 'Corporate CAS',
|
||||
methodTypes: ['CAS_REDIRECT'],
|
||||
usable: true,
|
||||
canUnlink: true,
|
||||
}],
|
||||
availableProviders: [{
|
||||
providerCode: 'cas-backup',
|
||||
displayName: 'Backup CAS',
|
||||
methodTypes: ['CAS_REDIRECT'],
|
||||
}],
|
||||
}
|
||||
|
||||
const html = renderToStaticMarkup(<IdentityLinkManager />)
|
||||
|
||||
expect(html).toContain('Corporate CAS')
|
||||
expect(html).toContain('Backup CAS')
|
||||
expect(html).not.toContain(
|
||||
'security.identityLinks.noReauthenticationMethod',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the browser-link success result after callback', () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
|
|
|
|||
|
|
@ -106,11 +106,18 @@ function errorMessage(error: unknown, fallback: string) {
|
|||
|
||||
function hasMethod(
|
||||
provider: IdentityLinkBinding | IdentityLinkProvider,
|
||||
method: 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD',
|
||||
method: 'OAUTH_REDIRECT' | 'CAS_REDIRECT' | 'DIRECT_PASSWORD',
|
||||
) {
|
||||
return provider.methodTypes.includes(method)
|
||||
}
|
||||
|
||||
function hasBrowserMethod(
|
||||
provider: IdentityLinkBinding | IdentityLinkProvider,
|
||||
) {
|
||||
return hasMethod(provider, 'OAUTH_REDIRECT')
|
||||
|| hasMethod(provider, 'CAS_REDIRECT')
|
||||
}
|
||||
|
||||
function CredentialFields({
|
||||
prefix,
|
||||
value,
|
||||
|
|
@ -413,7 +420,7 @@ export function IdentityLinkManager() {
|
|||
)
|
||||
: undefined
|
||||
const browserReauthenticationProviders = linkedProviders.filter(
|
||||
(provider) => provider.usable && hasMethod(provider, 'OAUTH_REDIRECT'),
|
||||
(provider) => provider.usable && hasBrowserMethod(provider),
|
||||
)
|
||||
const credentialReauthenticationProviders = linkedProviders.filter(
|
||||
(provider) => provider.usable && hasMethod(provider, 'DIRECT_PASSWORD'),
|
||||
|
|
@ -696,7 +703,7 @@ export function IdentityLinkManager() {
|
|||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{t('security.identityLinks.reauthenticated')}
|
||||
</div>
|
||||
{targetProvider && hasMethod(targetProvider, 'OAUTH_REDIRECT') ? (
|
||||
{targetProvider && hasBrowserMethod(targetProvider) ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as loginButton from './login-button'
|
||||
import type { AuthMethod } from '@/api/types'
|
||||
import {
|
||||
isBrowserLoginMethod,
|
||||
LoginButton,
|
||||
providerLogoSource,
|
||||
} from './login-button'
|
||||
|
||||
/**
|
||||
* LoginButton is a React component that renders OAuth login buttons from backend-provided
|
||||
* auth methods. It filters for OAUTH_REDIRECT method types and shows a loading state.
|
||||
* There are no exported pure functions, constants, or data transformations to unit-test.
|
||||
*
|
||||
* Full rendering tests would require a React test renderer, QueryClient provider,
|
||||
* and i18next setup. This file verifies the export surface.
|
||||
*/
|
||||
describe('login-button module exports', () => {
|
||||
function method(methodType: string): AuthMethod {
|
||||
return {
|
||||
id: methodType,
|
||||
methodType,
|
||||
provider: 'cas-main',
|
||||
displayName: 'Corporate CAS',
|
||||
actionUrl: '/api/v1/auth/cas/cas-main/login',
|
||||
}
|
||||
}
|
||||
|
||||
describe('login-button browser method projection', () => {
|
||||
it('exports LoginButton component', () => {
|
||||
expect(loginButton.LoginButton).toBeTypeOf('function')
|
||||
expect(LoginButton).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('accepts OAuth and CAS redirect methods only', () => {
|
||||
expect(isBrowserLoginMethod(method('OAUTH_REDIRECT'))).toBe(true)
|
||||
expect(isBrowserLoginMethod(method('CAS_REDIRECT'))).toBe(true)
|
||||
expect(isBrowserLoginMethod(method('PASSWORD'))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses bundled logos only for providers that have one', () => {
|
||||
expect(providerLogoSource('GitHub')).toBe('/github-logo.svg')
|
||||
expect(providerLogoSource('gitlab')).toBe('/gitlab-logo.svg')
|
||||
expect(providerLogoSource('cas-main')).toBeNull()
|
||||
expect(providerLogoSource('corp-oidc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import type { AuthMethod } from '@/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { useAuthMethods } from './use-auth-methods'
|
||||
|
||||
|
|
@ -6,14 +8,26 @@ interface LoginButtonProps {
|
|||
returnTo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate icon for a given OAuth provider.
|
||||
*/
|
||||
function OAuthIcon({ provider }: { provider: string }) {
|
||||
export function providerLogoSource(provider: string) {
|
||||
const normalizedProvider = provider.toLowerCase()
|
||||
if (normalizedProvider === 'github' || normalizedProvider === 'gitlab') {
|
||||
return `/${normalizedProvider}-logo.svg`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isBrowserLoginMethod(method: AuthMethod) {
|
||||
return method.methodType === 'OAUTH_REDIRECT' || method.methodType === 'CAS_REDIRECT'
|
||||
}
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
const logoSource = providerLogoSource(provider)
|
||||
if (!logoSource) {
|
||||
return <ShieldCheck aria-hidden="true" className="w-5 h-5 mr-3" />
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={`/${normalizedProvider}-logo.svg`}
|
||||
src={logoSource}
|
||||
alt={provider}
|
||||
className="w-5 h-5 mr-3"
|
||||
/>
|
||||
|
|
@ -21,13 +35,13 @@ function OAuthIcon({ provider }: { provider: string }) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Renders OAuth login buttons from the auth-method catalog returned by the backend.
|
||||
* Renders browser-redirect login methods from the backend catalog.
|
||||
*/
|
||||
export function LoginButton({ returnTo }: LoginButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useAuthMethods(returnTo)
|
||||
|
||||
const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT')
|
||||
const providers = (data ?? []).filter(isBrowserLoginMethod)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -51,11 +65,10 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
|
|||
window.location.href = provider.actionUrl
|
||||
}}
|
||||
>
|
||||
<OAuthIcon provider={provider.provider} />
|
||||
<ProviderIcon provider={provider.provider} />
|
||||
{t('loginButton.loginWith', { name: provider.displayName })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
21
web/src/features/auth/login-failure.test.ts
Normal file
21
web/src/features/auth/login-failure.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { loginFailureMessageKey } from './login-failure'
|
||||
|
||||
describe('login failure messages', () => {
|
||||
it('maps stable CAS failure reasons to localized messages', () => {
|
||||
expect(loginFailureMessageKey('casInvalidState'))
|
||||
.toBe('login.failure.casInvalidState')
|
||||
expect(loginFailureMessageKey('casValidationFailed'))
|
||||
.toBe('login.failure.casValidationFailed')
|
||||
expect(loginFailureMessageKey('casUnavailable'))
|
||||
.toBe('login.failure.casUnavailable')
|
||||
expect(loginFailureMessageKey('casReplayDetected'))
|
||||
.toBe('login.failure.casReplayDetected')
|
||||
})
|
||||
|
||||
it('does not render untrusted reason text', () => {
|
||||
expect(loginFailureMessageKey('<script>alert(1)</script>'))
|
||||
.toBeUndefined()
|
||||
expect(loginFailureMessageKey()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
14
web/src/features/auth/login-failure.ts
Normal file
14
web/src/features/auth/login-failure.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const LOGIN_FAILURE_MESSAGE_KEYS: Record<string, string> = {
|
||||
accountDisabled: 'apiError.auth.accountDisabled',
|
||||
linkRequired: 'login.failure.linkRequired',
|
||||
casInvalidState: 'login.failure.casInvalidState',
|
||||
casReplayDetected: 'login.failure.casReplayDetected',
|
||||
casTicketMissing: 'login.failure.casTicketMissing',
|
||||
casValidationFailed: 'login.failure.casValidationFailed',
|
||||
casUnavailable: 'login.failure.casUnavailable',
|
||||
internalError: 'login.failure.internalError',
|
||||
}
|
||||
|
||||
export function loginFailureMessageKey(reason?: string) {
|
||||
return reason ? LOGIN_FAILURE_MESSAGE_KEYS[reason] : undefined
|
||||
}
|
||||
|
|
@ -204,7 +204,7 @@
|
|||
"title": "Login to SkillHub",
|
||||
"subtitle": "Choose a method to continue",
|
||||
"tabPassword": "Password",
|
||||
"tabOAuth": "OAuth",
|
||||
"tabOAuth": "Enterprise SSO",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"usernamePlaceholder": "Enter username",
|
||||
|
|
@ -218,13 +218,22 @@
|
|||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"register": "Sign up now",
|
||||
"oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.",
|
||||
"oauthHint": "After authentication with your external identity provider, you will be redirected back to this site.",
|
||||
"passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.",
|
||||
"enterpriseSsoTitle": "Enterprise SSO",
|
||||
"enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.",
|
||||
"enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.",
|
||||
"enterpriseSsoAction": "Try {{name}}",
|
||||
"enterpriseSsoSubmitting": "Trying {{name}}...",
|
||||
"failure": {
|
||||
"linkRequired": "This external identity must be linked to an existing account before it can sign in.",
|
||||
"casInvalidState": "This CAS login request has expired or was already used. Start the login again.",
|
||||
"casReplayDetected": "This CAS login request was already used. The replay was rejected; start the login again.",
|
||||
"casTicketMissing": "The CAS server did not return a service ticket. Start the login again.",
|
||||
"casValidationFailed": "CAS could not verify this login. Start the login again or contact your administrator.",
|
||||
"casUnavailable": "CAS login is temporarily unavailable. Try again later or contact your administrator.",
|
||||
"internalError": "Login could not be completed because of an internal error. Try again later."
|
||||
},
|
||||
"agreementPrefix": "By logging in, you agree to our",
|
||||
"terms": "Terms of Service",
|
||||
"and": "and",
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@
|
|||
"title": "登录 SkillHub",
|
||||
"subtitle": "选择一个方式登录以继续",
|
||||
"tabPassword": "账号密码",
|
||||
"tabOAuth": "OAuth",
|
||||
"tabOAuth": "企业统一登录",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"usernamePlaceholder": "输入用户名",
|
||||
|
|
@ -218,13 +218,22 @@
|
|||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "还没有账号?",
|
||||
"register": "立即注册",
|
||||
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
|
||||
"oauthHint": "使用外部身份源完成认证后,会自动返回当前站点。",
|
||||
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
|
||||
"enterpriseSsoTitle": "企业单点登录",
|
||||
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",
|
||||
"enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。",
|
||||
"enterpriseSsoAction": "尝试 {{name}} 登录",
|
||||
"enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...",
|
||||
"failure": {
|
||||
"linkRequired": "该外部身份需要先关联已有账号,暂时不能直接登录。",
|
||||
"casInvalidState": "本次 CAS 登录请求已过期或已被使用,请重新发起登录。",
|
||||
"casReplayDetected": "本次 CAS 登录请求已被使用,系统已拒绝重复请求,请重新发起登录。",
|
||||
"casTicketMissing": "CAS 服务没有返回服务票据,请重新发起登录。",
|
||||
"casValidationFailed": "CAS 未能验证本次登录,请重试或联系管理员。",
|
||||
"casUnavailable": "CAS 登录暂时不可用,请稍后重试或联系管理员。",
|
||||
"internalError": "由于内部错误,登录未能完成,请稍后重试。"
|
||||
},
|
||||
"agreementPrefix": "登录即表示你同意我们的",
|
||||
"terms": "服务条款",
|
||||
"and": "和",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { getDirectAuthRuntimeConfig } from '@/api/client'
|
||||
import { loginFailureMessageKey } from '@/features/auth/login-failure'
|
||||
import { LoginButton } from '@/features/auth/login-button'
|
||||
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
|
||||
import { useAuthMethods } from '@/features/auth/use-auth-methods'
|
||||
|
|
@ -31,7 +32,8 @@ export function LoginPage() {
|
|||
const { data: authMethods } = useAuthMethods(search.returnTo)
|
||||
|
||||
const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard'
|
||||
const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null
|
||||
const failureMessageKey = loginFailureMessageKey(search.reason)
|
||||
const failureMessage = failureMessageKey ? t(failureMessageKey) : null
|
||||
const directMethod = directAuthConfig.provider
|
||||
? authMethods?.find((method) =>
|
||||
method.methodType === 'DIRECT_PASSWORD' && method.provider === directAuthConfig.provider)
|
||||
|
|
@ -78,9 +80,9 @@ export function LoginPage() {
|
|||
|
||||
<div className="glass-strong p-8 rounded-2xl">
|
||||
<div className="space-y-6">
|
||||
{disabledMessage ? (
|
||||
{failureMessage ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{disabledMessage}
|
||||
{failureMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<SessionBootstrapEntry
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue