diff --git a/.env.release.example b/.env.release.example index 1b8e38e6..b0a96e0e 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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) diff --git a/README.md b/README.md index 23b959c0..cd410882 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_zh.md b/README_zh.md index 20217483..d545de76 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 配置、身份映射、部署与验证 ## 核心特性 diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 4d071b88..9c736f0c 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -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)。 + ### 副本数配置 | 参数 | 描述 | 默认值 | diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 78e9e016..258c8dd5 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -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 }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 149cb203..b589f9ab 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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: diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index db34c848..03af8ecf 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -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" -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 94df9733..cb456cd9 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -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 diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 19be6440..10950c7a 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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" } + } + } + } } } }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 059ff73c..06c84251 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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 diff --git a/compose.release.yml b/compose.release.yml index db306bda..81cd5688 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 0ed6db14..b2d5acf6 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -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:///api/v1/auth/cas//callback +``` + +完整字段、身份映射和验证步骤见 +[`docs/23-cas-integration.md`](../../docs/23-cas-integration.md)。 + ### 3. 选择部署方式 **方式一:完整部署(包含 PostgreSQL + Redis)** diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 52ddc185..60eb97d3 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -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: diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 06dbbaa5..903a019c 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -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 diff --git a/docs/21-unified-identity-federation-design.md b/docs/21-unified-identity-federation-design.md index 2d96cb50..ae8ced4f 100644 --- a/docs/21-unified-identity-federation-design.md +++ b/docs/21-unified-identity-federation-design.md @@ -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 不要求必须由维护者亲自编码。更准确的规则是: diff --git a/docs/23-cas-integration.md b/docs/23-cas-integration.md new file mode 100644 index 00000000..f9e54b29 --- /dev/null +++ b/docs/23-cas-integration.md @@ -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:///api/v1/auth/cas//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。 diff --git a/scripts/tests/nginx-forwarded-proto-test.sh b/scripts/tests/nginx-forwarded-proto-test.sh index 01be85c3..c0ef65a1 100755 --- a/scripts/tests/nginx-forwarded-proto-test.sh +++ b/scripts/tests/nginx-forwarded-proto-test.sh @@ -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" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 7c7940a3..780602e3 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java new file mode 100644 index 00000000..22299c29 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CasLoginController.java @@ -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 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 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 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()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index d46a1e49..b1aabcc1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -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 SKIP_SUFFIXES = Set.of( "/sse" ); + private static final Set 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"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java index ce38aea7..5f183c75 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java @@ -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 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]"; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 3c53b781..7b651b1b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -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); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java new file mode 100644 index 00000000..67986c7d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java @@ -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 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 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 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 + 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 + 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 + 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); + } + +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java new file mode 100644 index 00000000..d1ca1449 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFailure.java @@ -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 +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFlowException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFlowException.java new file mode 100644 index 00000000..d45f88a2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginFlowException.java @@ -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; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java new file mode 100644 index 00000000..603fc777 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginStateStore.java @@ -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 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"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java index f2592055..e62bbe6d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java @@ -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( diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index eacd31f3..69812bf8 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java new file mode 100644 index 00000000..86e0dc5e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CasLoginControllerTest.java @@ -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"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index 11ec0aec..3a9c2942 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -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 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 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<>(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java index f68e819e..522ec465 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java @@ -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"); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java index 60cf5d86..50a81e7c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -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" + ); + }); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java new file mode 100644 index 00000000..d7ff41a0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java @@ -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 + route = + mock(IdentityProviderRegistry.BrowserRoute.class); + @SuppressWarnings("unchecked") + private final BrowserAuthenticationAdapter + 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); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java new file mode 100644 index 00000000..70c5d5f9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginStateStoreTest.java @@ -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 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 key = + ArgumentCaptor.forClass(String.class); + ArgumentCaptor 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 + .>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 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 + .>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 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 + .>any(), + anyList())) + .thenThrow(new IllegalStateException( + "redis unavailable")); + assertThatThrownBy( + () -> store.consume(SESSION_ID, STATE)) + .isInstanceOf( + CasLoginStateStore + .CasLoginStateStoreException.class); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java new file mode 100644 index 00000000..42f831f0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java @@ -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); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasAuthenticationAdapter.java new file mode 100644 index 00000000..c3f17f58 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasAuthenticationAdapter.java @@ -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 { + + 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 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> 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 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> target, + String targetKey, + Optional sourceKey, + Map> source, + ProviderAttributeTrust trust) { + if (sourceKey.isEmpty()) { + return; + } + List 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))); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasAuthenticationExchange.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasAuthenticationExchange.java new file mode 100644 index 00000000..d63bf869 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasAuthenticationExchange.java @@ -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> 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> copied = + new LinkedHashMap<>(); + attributes.forEach((key, values) -> { + Objects.requireNonNull(key, "CAS attribute key"); + List 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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasBrowserClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasBrowserClient.java new file mode 100644 index 00000000..cb61d344 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasBrowserClient.java @@ -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); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginInitiation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginInitiation.java new file mode 100644 index 00000000..2eae9bd8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginInitiation.java @@ -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"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java new file mode 100644 index 00000000..164036c8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java @@ -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; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java new file mode 100644 index 00000000..374721e7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolClient.java @@ -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. + * + *

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.

+ */ +@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> attributes = + parseJsonAttributes(success.path("attributes")); + return new CasAuthenticationExchange( + principal, + attributes, + clock.instant()); + } + + private Map> parseJsonAttributes( + JsonNode attributesNode) { + if (attributesNode.isMissingNode() || attributesNode.isNull()) { + return Map.of(); + } + if (!attributesNode.isObject() + || attributesNode.size() > MAX_ATTRIBUTE_COUNT) { + throw invalidResponse(); + } + Map> attributes = new LinkedHashMap<>(); + attributesNode.fields().forEachRemaining(entry -> { + JsonNode raw = entry.getValue(); + List 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 successes = childElements( + root, + "authenticationSuccess"); + List 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 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 attributesElements = + childElements(success, "attributes"); + if (attributesElements.size() > 1) { + throw invalidResponse(); + } + Map> 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> parseXmlAttributes( + Element attributesElement) { + Map> 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 existing = attributes.getOrDefault( + name, + List.of()); + if (existing.size() >= MAX_VALUES_PER_ATTRIBUTE) { + throw invalidResponse(); + } + List 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 childElements( + Element parent, + String localName) { + List 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> exchange = + client.sendAsync( + request, + responseInfo -> + new LimitedBodySubscriber( + maximumResponseBytes)); + try { + HttpResponse 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 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 { + + private final int maximumResponseBytes; + private final ByteArrayOutputStream output = + new ByteArrayOutputStream(); + private final CompletableFuture body = + new CompletableFuture<>(); + private Flow.Subscription subscription; + + private LimitedBodySubscriber( + int maximumResponseBytes) { + this.maximumResponseBytes = + maximumResponseBytes; + } + + @Override + public CompletionStage 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 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; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolVersion.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolVersion.java new file mode 100644 index 00000000..ceda88c9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProtocolVersion.java @@ -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"); + }; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProviderConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProviderConfiguration.java new file mode 100644 index 00000000..6a0af98d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProviderConfiguration.java @@ -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 subjectAttribute = + optionalAttribute(attributes.getSubject()); + Optional displayNameAttribute = + optionalAttribute(attributes.getDisplayName()); + Optional emailAttribute = + optionalAttribute(attributes.getEmail()); + Optional 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 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 subjectAttribute, + Optional displayNameAttribute, + Optional emailAttribute, + Optional avatarAttribute, + Duration connectTimeout, + Duration readTimeout, + Duration stateTtl, + int maximumResponseBytes + ) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/package-info.java new file mode 100644 index 00000000..2d8a2ebd --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/package-info.java @@ -0,0 +1,4 @@ +/** + * Standard CAS 2.0/3.0 protocol transport and identity-result adapter. + */ +package com.iflytek.skillhub.auth.cas; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java index d276449a..1d052940 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java @@ -19,8 +19,9 @@ import org.springframework.stereotype.Component; /** * Owns the raw, high-entropy session state used by Identity Link workflows. * - *

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. + *

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 diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java index 32e74a23..fee03fc1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java @@ -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, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java index 8fa56fc7..4bf616bb 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java @@ -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; }; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java index 7cd2b4e4..2f6e502e 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethodType.java @@ -5,6 +5,7 @@ package com.iflytek.skillhub.auth.identity; */ public enum IdentityProviderLoginMethodType { OAUTH_REDIRECT, + CAS_REDIRECT, DIRECT_PASSWORD, SESSION_BOOTSTRAP } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java index 30c279ee..73292642 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderRegistry.java @@ -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 listReadyLoginMethods(); + BrowserRoute requireBrowserRoute( + String providerCode, + Class exchangeType); + CredentialRoute requireCredentialRoute(String providerCode); PassiveRoute requirePassiveRoute(String providerCode); + class BrowserRoute { + private final ResolvedProviderHandle provider; + private final BrowserAuthenticationAdapter adapter; + + public BrowserRoute( + ResolvedProviderHandle provider, + BrowserAuthenticationAdapter adapter) { + this.provider = Objects.requireNonNull( + provider, + "provider"); + this.adapter = Objects.requireNonNull( + adapter, + "adapter"); + } + + public ResolvedProviderHandle provider() { + return provider; + } + + public BrowserAuthenticationAdapter adapter() { + return adapter; + } + } + class CredentialRoute { private final ResolvedProviderHandle provider; private final CredentialAuthenticationAdapter adapter; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java index 8de8fcf0..99db2dbd 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java @@ -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> browserAdapters; private final List credentialAdapters; private final List passiveAdapters; private final AtomicReference snapshot = @@ -52,12 +55,14 @@ class ReconciledIdentityProviderCatalog ProviderAuthorityLockService authorityLockService, IdentityBindingPreflightService bindingPreflightService, IdentityProviderPolicyProperties policyProperties, + List> browserAdapters, List credentialAdapters, List 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 descriptors = new LinkedHashMap<>(); - Set browserProviders = new HashSet<>(); + Set oauthBrowserProviders = new HashSet<>(); + Map> browsers = + new LinkedHashMap<>(); Map credentials = new LinkedHashMap<>(); Map 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 BrowserRoute requireBrowserRoute( + String providerCode, + Class 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 BrowserRoute browserRoute( + ProviderDescriptor descriptor, + BrowserRegistration registration, + Class exchangeType) { + BrowserAuthenticationAdapter adapter = + registration.adapter(); + if (!adapter.exchangeType().equals(exchangeType)) { + throw providerDisabled(); + } + return new BrowserRoute<>( + new DefaultResolvedProviderHandle( + descriptor.providerCode()), + (BrowserAuthenticationAdapter) adapter); + } + private boolean isCurrentlyReady(ProviderDescriptor descriptor) { try { authorityLockService.requirePinnedAuthority(descriptor); @@ -436,6 +528,11 @@ class ReconciledIdentityProviderCatalog IdentityFailureCode.PROVIDER_DISABLED); } + private record BrowserRegistration( + BrowserAuthenticationAdapter adapter, + String displayName) { + } + private record CredentialRegistration( CredentialAuthenticationAdapter adapter, String displayName) { @@ -448,7 +545,8 @@ class ReconciledIdentityProviderCatalog private record RegistrySnapshot( Map descriptors, - Set browserProviders, + Set oauthBrowserProviders, + Map> browsers, Map credentials, Map passives ) { @@ -457,6 +555,7 @@ class ReconciledIdentityProviderCatalog Map.of(), Set.of(), Map.of(), + Map.of(), Map.of()); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java index b32c3678..f0651d35 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java @@ -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. + * + *

The Spring Security client registration remains the registered browser + * provider. This extractor only maps its already verified exchange and is not + * independently routable.

*/ -public interface OAuthClaimsExtractor - extends BrowserAuthenticationAdapter { +public interface OAuthClaimsExtractor { String getProvider(); ProviderAuthenticationResult extract( OAuth2UserRequest request, OAuth2User oAuth2User); - @Override default ProviderAuthenticationResult authenticate( OAuthAuthenticationExchange exchange) { return extract(exchange.request(), exchange.user()); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index c4c52fff..28fbf4f8 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -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"), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java index ed72c081..e0bdd651 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationAdapter.java @@ -11,6 +11,12 @@ import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; */ public interface BrowserAuthenticationAdapter { + ProviderInstanceDefinition provider(); + + Class exchangeType(); + + BrowserAuthenticationMethod loginMethod(); + /** * @throws ProviderAuthenticationException when the verified exchange * cannot be accepted or its upstream is unavailable diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationMethod.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationMethod.java new file mode 100644 index 00000000..44d06651 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/provider/BrowserAuthenticationMethod.java @@ -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 +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasAuthenticationAdapterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasAuthenticationAdapterTest.java new file mode 100644 index 00000000..3fa585d2 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasAuthenticationAdapterTest.java @@ -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(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java new file mode 100644 index 00000000..cf329652 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientHttpIntegrationTest.java @@ -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 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientTest.java new file mode 100644 index 00000000..238e38be --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProtocolClientTest.java @@ -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 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, + """ + + + + alice + + engineering + reviewers + + + + """)); + + 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, + """ + + + alice + + + """)); + + assertThat(client.validate( + "cas-main", + "ST-xml", + client.begin("cas-main", STATE).serviceUrl()) + .principal()).isEqualTo("alice"); + } + + @Test + void invalidStateBoundServiceNeverReachesTheCasServer() { + AtomicReference 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, + """ + + + ]> + + + &xxe; + + + """)); + + 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, + """ + + + alice + + + """)); + + 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProviderConfigurationTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProviderConfigurationTest.java new file mode 100644 index 00000000..32b93122 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasProviderConfigurationTest.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTestConfiguration.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTestConfiguration.java new file mode 100644 index 00000000..e25fda44 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTestConfiguration.java @@ -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")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java new file mode 100644 index 00000000..3be46271 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransactionCasCapabilityTest.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java index 9d7af3a8..ca6d3bba 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java @@ -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 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 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) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index efb5b0bd..7df1ae00 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -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() diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java index 6a564cd4..a3f31112 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKit.java @@ -41,10 +41,10 @@ public final class ProviderConformanceKit { } public static ProviderAuthenticationResult verifyBrowser( - ProviderInstanceDefinition provider, BrowserAuthenticationAdapter adapter, T fixture) { - assertThat(provider).isNotNull(); + ProviderInstanceDefinition provider = + verifyDefinition(adapter.provider(), adapter.provider()); ProviderAuthenticationResult result = adapter.authenticate(fixture); verifyResult(provider, result); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java index 78c15ebc..cdff169e 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/provider/ProviderConformanceKitTest.java @@ -84,7 +84,28 @@ class ProviderConformanceKitTest { "X-Private-Assertion", List.of("fixture-assertion"))); BrowserAuthenticationAdapter browser = - exchange -> result; + new BrowserAuthenticationAdapter<>() { + @Override + public ProviderInstanceDefinition provider() { + return provider; + } + + @Override + public Class 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( diff --git a/web/nginx.conf.template b/web/nginx.conf.template index 25db2869..70c97eba 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -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; diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 22773753..b4316687 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bd3d19d5..578892f2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,9 +15,14 @@ export type OAuthProvider = Omit & { id: string - methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string + methodType: NonNullable provider: string displayName: string actionUrl: string diff --git a/web/src/features/auth/identity-link-manager.test.tsx b/web/src/features/auth/identity-link-manager.test.tsx index 8b66db00..2bfbffb0 100644 --- a/web/src/features/auth/identity-link-manager.test.tsx +++ b/web/src/features/auth/identity-link-manager.test.tsx @@ -163,6 +163,33 @@ describe('IdentityLinkManager', () => { expect(html).toMatch(/]*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() + + 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: { diff --git a/web/src/features/auth/identity-link-manager.tsx b/web/src/features/auth/identity-link-manager.tsx index ba68c26c..a1f484ed 100644 --- a/web/src/features/auth/identity-link-manager.tsx +++ b/web/src/features/auth/identity-link-manager.tsx @@ -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() {
{t('security.identityLinks.reauthenticated')}
- {targetProvider && hasMethod(targetProvider, 'OAUTH_REDIRECT') ? ( + {targetProvider && hasBrowserMethod(targetProvider) ? ( ))} ) } - diff --git a/web/src/features/auth/login-failure.test.ts b/web/src/features/auth/login-failure.test.ts new file mode 100644 index 00000000..afb7140d --- /dev/null +++ b/web/src/features/auth/login-failure.test.ts @@ -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('')) + .toBeUndefined() + expect(loginFailureMessageKey()).toBeUndefined() + }) +}) diff --git a/web/src/features/auth/login-failure.ts b/web/src/features/auth/login-failure.ts new file mode 100644 index 00000000..0f54cc64 --- /dev/null +++ b/web/src/features/auth/login-failure.ts @@ -0,0 +1,14 @@ +const LOGIN_FAILURE_MESSAGE_KEYS: Record = { + 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 +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index af0c9128..361501bb 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 94aa73e9..92de7c1d 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "和", diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 1aab99c4..245b4c1f 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -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() {
- {disabledMessage ? ( + {failureMessage ? (
- {disabledMessage} + {failureMessage}
) : null}