mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
test(auth): cover CLI session fallback (#605)
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
8163a48e9e
commit
5012b31af2
6 changed files with 432 additions and 104 deletions
|
|
@ -377,7 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台
|
|||
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
|
||||
- 存储:只存 SHA-256 哈希,明文只展示一次
|
||||
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
|
||||
- 失败闭合:共享认证过滤器只识别 Bearer scheme;公共读接口在未提供可识别的 Bearer 凭证时按匿名访问处理(包括缺少 `Authorization` 头,以及 Basic 或其他非 Bearer scheme)。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问
|
||||
- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份;Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401,即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session;若无 Session,公共读接口按匿名访问,`whoami` 返回 401
|
||||
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
|
||||
|
||||
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
|
||||
|
|
@ -623,13 +623,13 @@ window.location.href = '/oauth2/authorization/github'
|
|||
|
||||
| 接口 | 凭证规则 | 授权与错误语义 |
|
||||
|------|---------|---------------|
|
||||
| `GET /api/cli/v1/auth/whoami` | 必须提供有效 Bearer Token | 缺失、未知、过期、撤销或 malformed token 返回 401 |
|
||||
| `GET /api/cli/v1/skills/search` | 可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;坏凭证返回 401,不得降级匿名 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | 可匿名读取公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 |
|
||||
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||||
|
||||
共享 API token 过滤器只识别 Bearer scheme。公共读接口在未提供可识别的 Bearer 凭证时允许匿名访问:这既包括完全缺少 `Authorization` 头,也包括 Basic 或其他非 Bearer scheme;`whoami` 因自身要求认证,在这些情况下仍返回 401。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401,不能降级为匿名身份。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。
|
||||
Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session,确保请求使用 token 的用户、角色与 scope;Bearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401,不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session;如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`。
|
||||
|
||||
### 10.4 Admin API
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ info:
|
|||
title: SkillHub CLI Authentication API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Authentication contract for CLI identity and public skill reads. Public read
|
||||
operations treat a request with no recognized Bearer credential as anonymous,
|
||||
including an absent Authorization header or an unsupported scheme such as
|
||||
Basic. Once the Bearer scheme is used, the credential must be valid;
|
||||
malformed, unknown, expired, or revoked Bearer credentials return HTTP 401
|
||||
and never fall back to anonymous access.
|
||||
Authentication contract for CLI identity and public skill reads. A valid
|
||||
Bearer credential overrides a Web Session identity. Once the Bearer scheme
|
||||
is used, the credential must be valid: empty, malformed, unknown, expired,
|
||||
or revoked Bearer credentials return HTTP 401 and never fall back to the
|
||||
Session or anonymous access. An absent Authorization header or an
|
||||
unsupported scheme such as Basic preserves a valid Web Session. Without a
|
||||
Session, public reads use anonymous visibility and whoami returns HTTP 401.
|
||||
servers:
|
||||
- url: /
|
||||
tags:
|
||||
|
|
@ -20,8 +21,10 @@ paths:
|
|||
tags: [CLI Authentication]
|
||||
summary: Return the current CLI identity
|
||||
operationId: cliWhoAmI
|
||||
description: Requires a valid Bearer credential or Web Session. Bearer takes priority over Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session, but returns 401 when no Session exists.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Authenticated CLI identity
|
||||
|
|
@ -36,9 +39,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Search CLI-installable skills
|
||||
operationId: cliSearchSkills
|
||||
description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401.
|
||||
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: q
|
||||
|
|
@ -67,9 +71,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Resolve a skill version
|
||||
operationId: cliResolveSkill
|
||||
description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401.
|
||||
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Namespace'
|
||||
|
|
@ -98,9 +103,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Download the latest installable skill version
|
||||
operationId: cliDownloadLatestSkill
|
||||
description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401.
|
||||
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Namespace'
|
||||
|
|
@ -123,9 +129,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Download an exact installable skill version
|
||||
operationId: cliDownloadSkillVersion
|
||||
description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401.
|
||||
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Namespace'
|
||||
|
|
@ -150,7 +157,12 @@ components:
|
|||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: SkillHub API token
|
||||
description: API token issued by SkillHub. Invalid lifecycle states all return the same 401 response.
|
||||
description: API token issued by SkillHub. A valid token overrides Web Session; invalid lifecycle states all return the same 401 response without Session fallback.
|
||||
sessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: SESSION
|
||||
description: Spring Session browser identity. It is preserved when Authorization is absent or uses a non-Bearer scheme, and is overridden by a valid Bearer token.
|
||||
parameters:
|
||||
Namespace:
|
||||
name: namespace
|
||||
|
|
@ -194,7 +206,7 @@ components:
|
|||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
|
||||
Unauthorized:
|
||||
description: Bearer credential is missing where required, malformed, unknown, expired, revoked, or belongs to an unavailable user.
|
||||
description: No valid supported identity is present where required, or the Bearer credential is empty, malformed, unknown, expired, revoked, or belongs to an unavailable user. Invalid Bearer never falls back to Web Session.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
|
||||
|
|
@ -249,7 +261,7 @@ components:
|
|||
properties:
|
||||
handle: {type: string, example: user-123}
|
||||
displayName: {type: string, example: CLI User}
|
||||
email: {type: string, format: email, example: cli@example.com}
|
||||
email: {type: string, format: email, nullable: true, example: cli@example.com, description: Email address when available; the required field is null when the account has no email.}
|
||||
CliSearchEnvelope:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Lock the CLI API's fail-closed Bearer behavior with persisted token lifecycle tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact.
|
||||
**Goal:** Lock the CLI API's fail-closed Bearer behavior and Web Session fallback with persisted lifecycle and mixed-credential tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact.
|
||||
|
||||
**Architecture:** Keep `ApiTokenAuthenticationFilter` as the sole Bearer authentication entry point. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization and a persisted PRIVATE skill for resource-level 403 checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned.
|
||||
**Architecture:** Keep `ApiTokenAuthenticationFilter` as the sole Bearer authentication entry point while preserving Spring Security's existing Web Session identity. Valid Bearer replaces Session; invalid Bearer fails closed without Session fallback; absent or non-Bearer Authorization preserves Session and otherwise leaves public reads anonymous. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization plus persisted PRIVATE and matching PUBLIC skills for authorization checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA/H2, MockMvc, JUnit 5 parameterized tests, Mockito, OpenAPI 3.0 YAML, Docker/OCI image inspection.
|
||||
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java`: persisted valid/revoked/expired/unknown/empty/malformed credential matrix for each CLI endpoint.
|
||||
- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`: real PRIVATE-skill search omission and read authorization through resolve, latest download, and versioned download.
|
||||
- Modify `docs/03-authentication-design.md`: current CLI route table and explicit anonymous/401/403 rules.
|
||||
- Modify `docs/03-authentication-design.md`: current CLI route table, Web Session/Bearer priority, and explicit anonymous/401/403 rules.
|
||||
- Create `docs/api/authentication.openapi.yaml`: OpenAPI 3.0 contract for whoami, search, resolve, latest download, and versioned download.
|
||||
- Do not modify `server/skillhub-auth/src/main/**` unless Task 5 records a failing unmodified-source assertion and a separate systematic-debugging plan amendment identifies the root cause.
|
||||
|
||||
|
|
@ -689,13 +689,13 @@ Use this content in section 10.3:
|
|||
|
||||
| 接口 | 凭证规则 | 授权与错误语义 |
|
||||
|------|---------|---------------|
|
||||
| `GET /api/cli/v1/auth/whoami` | 必须提供有效 Bearer Token | 缺失、未知、过期、撤销或 malformed token 返回 401 |
|
||||
| `GET /api/cli/v1/skills/search` | 可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;坏凭证返回 401,不得降级匿名 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | 可匿名读取公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 |
|
||||
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||||
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||||
|
||||
共享 API token 过滤器只识别 Bearer scheme。公共读接口在未提供可识别的 Bearer 凭证时允许匿名访问:这既包括完全缺少 `Authorization` 头,也包括 Basic 或其他非 Bearer scheme;`whoami` 因自身要求认证,在这些情况下仍返回 401。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401,不能降级为匿名身份。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。
|
||||
Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 覆盖 Session;坏 Bearer 清除当前身份并立即返回 401,不回退 Session 或匿名。没有 Authorization 或使用 Basic/其他非 Bearer scheme 时保留 Session;如果 Session 也不存在,公共读匿名而 `whoami` 返回 401。身份已验证但 token scope 或资源权限不足时返回 403。`whoami.email` 字段始终存在,没有邮箱时为 `null`。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the complete OpenAPI 3.0 document**
|
||||
|
|
@ -708,12 +708,11 @@ info:
|
|||
title: SkillHub CLI Authentication API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Authentication contract for CLI identity and public skill reads. Public read
|
||||
operations treat a request with no recognized Bearer credential as anonymous,
|
||||
including an absent Authorization header or an unsupported scheme such as
|
||||
Basic. Once the Bearer scheme is used, the credential must be valid;
|
||||
malformed, unknown, expired, or revoked Bearer credentials return HTTP 401
|
||||
and never fall back to anonymous access.
|
||||
Authentication contract for CLI identity and public skill reads. Valid
|
||||
Bearer overrides Web Session. Invalid Bearer returns HTTP 401 without
|
||||
Session fallback. An absent Authorization header or unsupported scheme such
|
||||
as Basic preserves Session; without Session, public reads are anonymous and
|
||||
whoami returns HTTP 401.
|
||||
servers:
|
||||
- url: /
|
||||
tags:
|
||||
|
|
@ -725,8 +724,10 @@ paths:
|
|||
tags: [CLI Authentication]
|
||||
summary: Return the current CLI identity
|
||||
operationId: cliWhoAmI
|
||||
description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Authenticated CLI identity
|
||||
|
|
@ -741,9 +742,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Search CLI-installable skills
|
||||
operationId: cliSearchSkills
|
||||
description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401.
|
||||
description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: q
|
||||
|
|
@ -772,8 +774,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Resolve a skill version
|
||||
operationId: cliResolveSkill
|
||||
description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Namespace'
|
||||
|
|
@ -802,8 +806,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Download the latest installable skill version
|
||||
operationId: cliDownloadLatestSkill
|
||||
description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Namespace'
|
||||
|
|
@ -826,8 +832,10 @@ paths:
|
|||
tags: [CLI Skills]
|
||||
summary: Download an exact installable skill version
|
||||
operationId: cliDownloadSkillVersion
|
||||
description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility.
|
||||
security:
|
||||
- {}
|
||||
- sessionAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Namespace'
|
||||
|
|
@ -852,7 +860,12 @@ components:
|
|||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: SkillHub API token
|
||||
description: API token issued by SkillHub. Invalid lifecycle states all return the same 401 response.
|
||||
description: API token issued by SkillHub. Valid Bearer overrides Session; invalid lifecycle states return the same 401 response without Session fallback.
|
||||
sessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: SESSION
|
||||
description: Spring Session browser identity, preserved when Authorization is absent or uses a non-Bearer scheme.
|
||||
parameters:
|
||||
Namespace:
|
||||
name: namespace
|
||||
|
|
@ -896,7 +909,7 @@ components:
|
|||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
|
||||
Unauthorized:
|
||||
description: Bearer credential is missing where required, malformed, unknown, expired, revoked, or belongs to an unavailable user.
|
||||
description: No valid supported identity is present where required, or the Bearer credential is invalid. Invalid Bearer never falls back to Web Session.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
|
||||
|
|
@ -951,7 +964,7 @@ components:
|
|||
properties:
|
||||
handle: {type: string, example: user-123}
|
||||
displayName: {type: string, example: CLI User}
|
||||
email: {type: string, format: email, example: cli@example.com}
|
||||
email: {type: string, format: email, nullable: true, example: cli@example.com}
|
||||
CliSearchEnvelope:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
|
|
@ -1056,7 +1069,111 @@ Expected after revocation: 401 on every endpoint. If behavior differs, preserve
|
|||
|
||||
If no affected runtime URL, host/replica access, or authorization to create/revoke a test token is available, explicitly escalate to the human owner in the active issue. Name the missing authority and request the exact evidence still required: deployed version, immutable server digest or build SHA, all replica identities, and same-token valid-to-revoked replay. State that repository tests do not close the field contradiction and therefore cannot justify closing the defect.
|
||||
|
||||
### Task 8: Quality gates and implementation review handoff
|
||||
### Task 8: Preserve Web Session fallback and harden the reviewed contracts
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java`
|
||||
- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`
|
||||
- Modify: `docs/03-authentication-design.md`
|
||||
- Modify: `docs/api/authentication.openapi.yaml`
|
||||
- Modify: `docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md`
|
||||
|
||||
- [ ] **Step 1: Add the five-endpoint Web Session and mixed-credential matrix**
|
||||
|
||||
Add independent arguments for `whoami`, search, resolve, latest download, and
|
||||
versioned download. For each endpoint exercise Session-only, Session + Basic,
|
||||
Basic-only, and Session + valid Bearer. Persist distinct Session and token
|
||||
users, assert Session identity is retained when Bearer is absent or the scheme
|
||||
is Basic, assert public reads are anonymous for Basic-only, and assert valid
|
||||
Bearer identity replaces Session identity. Existing revoked, expired, unknown,
|
||||
empty, and malformed Bearer cases must attach a real mock HTTP Session and
|
||||
continue to return the fixed five-field 401 envelope before controller service
|
||||
logic runs.
|
||||
|
||||
Run a reversible filter mutation that prevents valid Bearer replacement of an
|
||||
existing Session principal, then run:
|
||||
|
||||
```bash
|
||||
cd server && ./mvnw -pl skillhub-app -am \
|
||||
-Dtest=CliTokenLifecycleSecurityIntegrationTest#sessionAndAuthorizationSchemeMatrix \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected RED: Session + valid Bearer exposes the Session user instead of the
|
||||
token user. Restore production source immediately and rerun the same command.
|
||||
Expected GREEN: all 20 endpoint/credential arguments pass without a production
|
||||
source diff.
|
||||
|
||||
- [ ] **Step 2: Lock the nullable whoami email contract**
|
||||
|
||||
Persist an active user whose email is `null`, issue its token through
|
||||
`ApiTokenService`, call `GET /api/cli/v1/auth/whoami`, and assert the `email`
|
||||
key is present with a JSON null value inside the standard five-field envelope.
|
||||
|
||||
```bash
|
||||
cd server && ./mvnw -pl skillhub-app -am \
|
||||
-Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiReturnsNullEmailForPersistedUserWithoutEmail \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: PASS against existing production behavior; this is a response-shape
|
||||
characterization test. Update `CliWhoAmI.email` in OpenAPI to remain required
|
||||
while becoming `nullable: true`.
|
||||
|
||||
- [ ] **Step 3: Make PRIVATE search omission a positive and negative proof**
|
||||
|
||||
Use a unique numeric `skillSlug` as `q`, persist an installable PUBLIC skill
|
||||
whose search document contains the same keyword, and keep the existing
|
||||
installable PRIVATE skill. Assert the PUBLIC slug is returned and the PRIVATE
|
||||
slug is omitted for the outsider token.
|
||||
|
||||
```bash
|
||||
cd server && ./mvnw -pl skillhub-app -am \
|
||||
-Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected RED before the PUBLIC fixture is persisted: the expected PUBLIC slug
|
||||
is absent. Expected GREEN after the fixture is added: the same non-empty result
|
||||
contains PUBLIC and omits PRIVATE.
|
||||
|
||||
- [ ] **Step 4: Assert the fixed five-field 403 envelope on every restricted read**
|
||||
|
||||
Replace status/code-only assertions for restricted resolve, latest download,
|
||||
and versioned download with a shared assertion for exactly `code`, `msg`,
|
||||
`data`, `timestamp`, and `requestId`; require `code=403`, `data=null`, and
|
||||
string timestamps/request IDs. Keep the three routes as separate test methods.
|
||||
|
||||
```bash
|
||||
cd server && ./mvnw -pl skillhub-app -am \
|
||||
-Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: all three pass through the real access-denied path.
|
||||
|
||||
- [ ] **Step 5: Align authentication design and OpenAPI priority rules**
|
||||
|
||||
Document these exact rules: valid Bearer overrides Web Session; any Bearer
|
||||
attempt that is empty, malformed, unknown, expired, revoked, or tied to an
|
||||
unavailable user returns 401 without Session fallback; no Authorization header
|
||||
or a non-Bearer scheme preserves a valid Session; without a Session, public
|
||||
reads use anonymous visibility and `whoami` returns 401. Add cookie
|
||||
`sessionAuth` to OpenAPI and list it as an alternative on all five operations.
|
||||
OpenAPI descriptions must state the precedence because security alternatives
|
||||
cannot encode it alone.
|
||||
|
||||
- [ ] **Step 6: Confirm the review correction did not change production auth**
|
||||
|
||||
```bash
|
||||
git diff --name-only origin/main...HEAD
|
||||
git diff --exit-code origin/main...HEAD -- server/skillhub-auth/src/main server/skillhub-app/src/main
|
||||
```
|
||||
|
||||
Expected: only tests and documentation changed; the production-code diff
|
||||
command exits 0.
|
||||
|
||||
### Task 9: Quality gates and implementation review handoff
|
||||
|
||||
**Files:**
|
||||
- Verify all changed files; do not create a PR in this stage.
|
||||
|
|
@ -1111,6 +1228,11 @@ Expected: only the approved spec/plan, two test classes, authentication design,
|
|||
|
||||
Provide the branch, focused commands, complete matrix result, 403 fixture result, docs path, runtime identity/replay evidence or explicit external blocker, and full gate output to the project tester. After tester passes, request structured reviewer/security review. Address any findings on the same branch and rerun affected gates.
|
||||
|
||||
- [ ] **Step 7: Report completion without creating a PR**
|
||||
- [ ] **Step 7: Update the existing single PR and report completion**
|
||||
|
||||
Post the implementation result to the active issue thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence, GREEN results, quality gates, OpenAPI path, production-code decision, and runtime identity/replay status. Do not create a PR, do not change issue status, and do not merge `main` during this stage.
|
||||
Commit and push to the existing `fix/auth-revoked-token-validation` branch so
|
||||
PR #609 updates in place. Post the implementation result to the active issue
|
||||
thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence,
|
||||
GREEN results, quality gates, OpenAPI path, production-code decision, and
|
||||
runtime identity/replay status. Do not create a second PR, do not change issue
|
||||
status, and do not merge `main` during this stage.
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
|
||||
Prove and preserve fail-closed API-token behavior across the CLI API using a
|
||||
real persisted token lifecycle. Invalid Bearer credentials must return HTTP
|
||||
401 before endpoint business logic runs, while requests without a recognized
|
||||
Bearer credential retain the existing anonymous-public-read contract. This
|
||||
includes an absent `Authorization` header and unsupported schemes such as
|
||||
Basic. Valid credentials without sufficient authorization continue to return
|
||||
HTTP 403.
|
||||
401 before endpoint business logic runs, including when a valid Web Session is
|
||||
also present. A valid Bearer credential overrides the Session identity. When
|
||||
Bearer is absent or the Authorization scheme is unsupported, the existing Web
|
||||
Session identity is preserved; without a valid Session, public reads remain
|
||||
anonymous and `whoami` returns 401. Valid credentials without sufficient
|
||||
authorization continue to return HTTP 403.
|
||||
|
||||
## Scope
|
||||
|
||||
|
|
@ -23,8 +24,10 @@ This change covers the following CLI routes:
|
|||
It also covers the authenticated-versus-forbidden boundary on the affected
|
||||
restricted read routes. An existing scope-protected CLI route may provide
|
||||
supplementary scope-filter evidence only. This change does not add endpoints,
|
||||
change response fields, change token storage, add a database migration, or
|
||||
change anonymous resource visibility rules.
|
||||
change runtime response fields, change token storage, add a database migration,
|
||||
or change anonymous resource visibility rules. The OpenAPI correction marks
|
||||
the already-nullable `whoami.email` value accurately without changing its JSON
|
||||
field presence.
|
||||
|
||||
## Current-State Finding
|
||||
|
||||
|
|
@ -101,9 +104,13 @@ source-code conclusion is accepted.
|
|||
## Architecture
|
||||
|
||||
`ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry
|
||||
point. It ignores Basic and other non-Bearer schemes, which therefore reach
|
||||
public read routes as anonymous requests; controllers must not duplicate token
|
||||
parsing or lifecycle checks.
|
||||
point. Spring Security loads an existing Web Session identity before the token
|
||||
filter runs. A valid Bearer token replaces that identity; an invalid, empty, or
|
||||
malformed Bearer attempt clears it and returns 401. The filter ignores Basic
|
||||
and other non-Bearer schemes, preserving the loaded Session identity. If no
|
||||
Session exists, those schemes reach public reads anonymously and `whoami`
|
||||
returns 401. Controllers must not duplicate token parsing, Session resolution,
|
||||
or lifecycle checks.
|
||||
|
||||
The regression test will boot the Spring application with MockMvc, real
|
||||
`ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI
|
||||
|
|
@ -154,12 +161,15 @@ arguments and assertions for every credential state.
|
|||
|---|---:|---:|---:|---:|---:|---|
|
||||
| No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public |
|
||||
| Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts |
|
||||
| Valid Web Session, no `Authorization` header | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Existing browser identity is preserved |
|
||||
| Valid Web Session + Basic | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Non-Bearer schemes do not erase Session identity |
|
||||
| Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected |
|
||||
| Revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous |
|
||||
| Expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous |
|
||||
| Unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous |
|
||||
| Empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic |
|
||||
| Malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic |
|
||||
| Valid Web Session + valid active token | 200 as token user | 200 as token user | 200 as token user | Existing 200/302 as token user | Existing 200/302 as token user | Bearer identity overrides Session identity |
|
||||
| Valid Web Session + revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
|
||||
| Valid Web Session + expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
|
||||
| Valid Web Session + unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
|
||||
| Valid Web Session + empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic |
|
||||
| Valid Web Session + malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic |
|
||||
|
||||
The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and
|
||||
the real read-authorization path:
|
||||
|
|
@ -190,13 +200,14 @@ evidence for the API-token scope filter only.
|
|||
Two documentation updates are required:
|
||||
|
||||
1. Update `docs/03-authentication-design.md` so the CLI API section uses the
|
||||
current `/api/cli/v1/...` routes and explicitly states the 401/403 and
|
||||
anonymous-access boundary.
|
||||
current `/api/cli/v1/...` routes and explicitly states Bearer-over-Session
|
||||
priority, Session fallback, and the anonymous/401/403 boundary.
|
||||
2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document
|
||||
must define Bearer authentication, all affected paths, query/path
|
||||
parameters, success schemas, the common response envelope, HTTP 401 and 403
|
||||
responses, examples, and the rule that absent credentials are allowed only
|
||||
on existing public-read routes.
|
||||
must define Bearer and Web Session authentication, all affected paths,
|
||||
query/path parameters, success schemas, the common response envelope, HTTP
|
||||
401 and 403 responses, examples, credential priority, and the rule that
|
||||
requests without either identity are allowed only on existing public-read
|
||||
routes. `CliWhoAmI.email` remains required but is nullable.
|
||||
|
||||
No controller signature or response schema changes are planned. Therefore the
|
||||
generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a
|
||||
|
|
@ -217,7 +228,12 @@ steps rather than collapsing them into one generic download case:
|
|||
the real read-authorization path to prove 403 for restricted `resolve`,
|
||||
latest download, and versioned download and success for an authorized user.
|
||||
6. Update the authentication design and OpenAPI contract.
|
||||
7. Identify the published/running image and replay the valid-to-revoked token
|
||||
7. Exercise Session-only, Session + Basic, Basic-only, and Session + valid or
|
||||
invalid Bearer independently on all five endpoints; latest and versioned
|
||||
download remain separate cases.
|
||||
8. Prove PRIVATE search omission with a non-empty same-keyword PUBLIC result
|
||||
and assert the fixed five-field 403 envelope on each restricted read.
|
||||
9. Identify the published/running image and replay the valid-to-revoked token
|
||||
lifecycle against that exact digest, or record the external access blocker
|
||||
without treating the field contradiction as resolved.
|
||||
|
||||
|
|
@ -247,8 +263,8 @@ Verification proceeds in this order:
|
|||
10. Replay the same valid-to-revoked token lifecycle against the identified
|
||||
runtime and record endpoint-level status, request ID, and replica evidence,
|
||||
keeping latest and versioned download results separate.
|
||||
11. Perform structured security and code review before opening the single final
|
||||
pull request.
|
||||
11. Perform structured security and code review before updating the existing
|
||||
single final pull request.
|
||||
|
||||
## Delivery Constraints
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
|
|||
import static org.hamcrest.Matchers.aMapWithSize;
|
||||
import static org.hamcrest.Matchers.hasItem;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
|
@ -47,6 +48,7 @@ class CliRestrictedReadAuthorizationIntegrationTest {
|
|||
|
||||
private String namespaceSlug;
|
||||
private String skillSlug;
|
||||
private String publicSkillSlug;
|
||||
private String version;
|
||||
private String ownerToken;
|
||||
private String outsiderToken;
|
||||
|
|
@ -57,7 +59,8 @@ class CliRestrictedReadAuthorizationIntegrationTest {
|
|||
String ownerId = "private-owner-" + suffix;
|
||||
String outsiderId = "private-outsider-" + suffix;
|
||||
namespaceSlug = "private-ns-" + suffix;
|
||||
skillSlug = "private-skill-" + suffix;
|
||||
skillSlug = Long.toUnsignedString(UUID.randomUUID().getMostSignificantBits());
|
||||
publicSkillSlug = "public-skill-" + suffix;
|
||||
version = "1.0.0";
|
||||
|
||||
userAccountRepository.save(new UserAccount(
|
||||
|
|
@ -94,45 +97,77 @@ class CliRestrictedReadAuthorizationIntegrationTest {
|
|||
"",
|
||||
SkillVisibility.PRIVATE.name(),
|
||||
skill.getStatus().name()));
|
||||
|
||||
Skill publicSkill = skillRepository.save(new Skill(
|
||||
namespace.getId(), publicSkillSlug, ownerId, SkillVisibility.PUBLIC));
|
||||
SkillVersion publicPublished = new SkillVersion(publicSkill.getId(), version, ownerId);
|
||||
publicPublished.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
publicPublished.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z"));
|
||||
publicPublished.setDownloadReady(true);
|
||||
publicPublished = skillVersionRepository.save(publicPublished);
|
||||
publicSkill.setLatestVersionId(publicPublished.getId());
|
||||
skillRepository.save(publicSkill);
|
||||
skillRepository.flush();
|
||||
skillVersionRepository.flush();
|
||||
skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity(
|
||||
publicSkill.getId(),
|
||||
namespace.getId(),
|
||||
namespaceSlug,
|
||||
ownerId,
|
||||
skillSlug,
|
||||
"Public match for " + publicSkillSlug,
|
||||
"public",
|
||||
skillSlug,
|
||||
"",
|
||||
SkillVisibility.PUBLIC.name(),
|
||||
publicSkill.getStatus().name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outsiderSearchOmitsPersistedPrivateSkill() throws Exception {
|
||||
void outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill() throws Exception {
|
||||
mockMvc.perform(withBearer(
|
||||
get("/api/cli/v1/skills/search").param("limit", "20"),
|
||||
get("/api/cli/v1/skills/search")
|
||||
.param("q", skillSlug)
|
||||
.param("limit", "20"),
|
||||
outsiderToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", aMapWithSize(5)))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.items[*].slug", hasItem(publicSkillSlug)))
|
||||
.andExpect(jsonPath("$.data.items[*].slug", not(hasItem(skillSlug))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outsiderCannotResolvePrivateSkill() throws Exception {
|
||||
mockMvc.perform(withBearer(
|
||||
get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
|
||||
outsiderToken))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
assertForbiddenEnvelope(withBearer(
|
||||
get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
|
||||
outsiderToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outsiderCannotDownloadLatestPrivateSkill() throws Exception {
|
||||
mockMvc.perform(withBearer(
|
||||
get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug),
|
||||
outsiderToken))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
assertForbiddenEnvelope(withBearer(
|
||||
get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug),
|
||||
outsiderToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outsiderCannotDownloadVersionedPrivateSkill() throws Exception {
|
||||
mockMvc.perform(withBearer(
|
||||
get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download",
|
||||
namespaceSlug, skillSlug, version),
|
||||
outsiderToken))
|
||||
assertForbiddenEnvelope(withBearer(
|
||||
get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download",
|
||||
namespaceSlug, skillSlug, version),
|
||||
outsiderToken));
|
||||
}
|
||||
|
||||
private void assertForbiddenEnvelope(MockHttpServletRequestBuilder request) throws Exception {
|
||||
mockMvc.perform(request)
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
.andExpect(jsonPath("$", aMapWithSize(5)))
|
||||
.andExpect(jsonPath("$.code").value(403))
|
||||
.andExpect(jsonPath("$.msg").isString())
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.timestamp").isString())
|
||||
.andExpect(jsonPath("$.requestId").isString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -8,16 +8,21 @@ import com.iflytek.skillhub.domain.user.UserAccount;
|
|||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
|
||||
import com.iflytek.skillhub.service.cli.CliSkillAppService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
|
@ -27,20 +32,26 @@ import org.springframework.http.HttpHeaders;
|
|||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import static org.hamcrest.Matchers.aMapWithSize;
|
||||
import static org.hamcrest.Matchers.hasKey;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
|
@ -59,6 +70,21 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
MALFORMED
|
||||
}
|
||||
|
||||
private enum EndpointCase {
|
||||
WHOAMI,
|
||||
SEARCH,
|
||||
RESOLVE,
|
||||
LATEST_DOWNLOAD,
|
||||
VERSIONED_DOWNLOAD
|
||||
}
|
||||
|
||||
private enum MixedCredentialState {
|
||||
SESSION_ONLY,
|
||||
SESSION_BASIC,
|
||||
BASIC_ONLY,
|
||||
SESSION_VALID_BEARER
|
||||
}
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
@Autowired ApiTokenService apiTokenService;
|
||||
@Autowired ApiTokenRepository apiTokenRepository;
|
||||
|
|
@ -67,12 +93,16 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
@MockBean CliSkillAppService cliSkillAppService;
|
||||
|
||||
private String userId;
|
||||
private String sessionUserId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = "token-matrix-" + UUID.randomUUID();
|
||||
sessionUserId = "session-matrix-" + UUID.randomUUID();
|
||||
userAccountRepository.save(new UserAccount(
|
||||
userId, "Token Matrix", userId + "@example.com", ""));
|
||||
userAccountRepository.save(new UserAccount(
|
||||
sessionUserId, "Session Matrix", sessionUserId + "@example.com", ""));
|
||||
given(cliSkillAppService.search(any(), anyInt(), any(), any()))
|
||||
.willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20));
|
||||
given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any()))
|
||||
|
|
@ -100,13 +130,54 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
.andExpect(jsonPath("$.data.handle").value(userId));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} with {1}")
|
||||
@MethodSource("mixedCredentialMatrix")
|
||||
void sessionAndAuthorizationSchemeMatrix(
|
||||
EndpointCase endpoint,
|
||||
MixedCredentialState credentialState) throws Exception {
|
||||
clearInvocations(cliSkillAppService);
|
||||
String expectedUserId = expectedUserId(credentialState);
|
||||
MockHttpServletRequestBuilder request = withCredentials(requestFor(endpoint), credentialState);
|
||||
|
||||
if (endpoint == EndpointCase.WHOAMI) {
|
||||
if (credentialState == MixedCredentialState.BASIC_ONLY) {
|
||||
assertUnauthorizedEnvelope(request);
|
||||
} else {
|
||||
assertSuccessEnvelope(request)
|
||||
.andExpect(jsonPath("$.data.handle").value(expectedUserId));
|
||||
}
|
||||
verifyNoInteractions(cliSkillAppService);
|
||||
return;
|
||||
}
|
||||
|
||||
ResultActions result = mockMvc.perform(request).andExpect(status().isOk());
|
||||
if (endpoint == EndpointCase.LATEST_DOWNLOAD
|
||||
|| endpoint == EndpointCase.VERSIONED_DOWNLOAD) {
|
||||
result.andExpect(content().contentType("application/zip"));
|
||||
} else {
|
||||
result.andExpect(jsonPath("$", aMapWithSize(5)))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
assertProjectedUser(endpoint, expectedUserId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void whoamiReturnsNullEmailForPersistedUserWithoutEmail() throws Exception {
|
||||
String noEmailUserId = "token-no-email-" + UUID.randomUUID();
|
||||
userAccountRepository.save(new UserAccount(noEmailUserId, "No Email User", null, ""));
|
||||
String rawToken = apiTokenService.createToken(
|
||||
noEmailUserId, "no-email-" + UUID.randomUUID(), "[\"skill:read\"]").rawToken();
|
||||
|
||||
assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken))
|
||||
.andExpect(jsonPath("$.data", hasKey("email")))
|
||||
.andExpect(jsonPath("$.data.email").value(nullValue()));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "whoami rejects {0}")
|
||||
@EnumSource(InvalidCredentialState.class)
|
||||
void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
|
||||
clearInvocations(cliSkillAppService);
|
||||
mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(401));
|
||||
assertUnauthorizedEnvelope(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state));
|
||||
verifyNoInteractions(cliSkillAppService);
|
||||
}
|
||||
|
||||
|
|
@ -128,10 +199,8 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
@EnumSource(InvalidCredentialState.class)
|
||||
void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
|
||||
clearInvocations(cliSkillAppService);
|
||||
mockMvc.perform(withInvalidBearer(
|
||||
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(401));
|
||||
assertUnauthorizedEnvelope(withInvalidBearer(
|
||||
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state));
|
||||
verifyNoInteractions(cliSkillAppService);
|
||||
}
|
||||
|
||||
|
|
@ -152,9 +221,8 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
@EnumSource(InvalidCredentialState.class)
|
||||
void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
|
||||
clearInvocations(cliSkillAppService);
|
||||
mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(401));
|
||||
assertUnauthorizedEnvelope(withInvalidBearer(
|
||||
get("/api/cli/v1/skills/global/demo/resolve"), state));
|
||||
verifyNoInteractions(cliSkillAppService);
|
||||
}
|
||||
|
||||
|
|
@ -176,9 +244,8 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
@EnumSource(InvalidCredentialState.class)
|
||||
void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
|
||||
clearInvocations(cliSkillAppService);
|
||||
mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(401));
|
||||
assertUnauthorizedEnvelope(withInvalidBearer(
|
||||
get("/api/cli/v1/skills/global/demo/download"), state));
|
||||
verifyNoInteractions(cliSkillAppService);
|
||||
}
|
||||
|
||||
|
|
@ -201,10 +268,8 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
@EnumSource(InvalidCredentialState.class)
|
||||
void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
|
||||
clearInvocations(cliSkillAppService);
|
||||
mockMvc.perform(withInvalidBearer(
|
||||
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(401));
|
||||
assertUnauthorizedEnvelope(withInvalidBearer(
|
||||
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state));
|
||||
verifyNoInteractions(cliSkillAppService);
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +337,70 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
InvalidCredentialState state) {
|
||||
return request
|
||||
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(state))
|
||||
.with(authentication(sessionAuthentication()));
|
||||
.session(session());
|
||||
}
|
||||
|
||||
private static Stream<Arguments> mixedCredentialMatrix() {
|
||||
return Stream.of(EndpointCase.values())
|
||||
.flatMap(endpoint -> Stream.of(MixedCredentialState.values())
|
||||
.map(state -> Arguments.of(endpoint, state)));
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder requestFor(EndpointCase endpoint) {
|
||||
return switch (endpoint) {
|
||||
case WHOAMI -> get("/api/cli/v1/auth/whoami");
|
||||
case SEARCH -> get("/api/cli/v1/skills/search")
|
||||
.param("q", "demo")
|
||||
.param("limit", "20");
|
||||
case RESOLVE -> get("/api/cli/v1/skills/global/demo/resolve");
|
||||
case LATEST_DOWNLOAD -> get("/api/cli/v1/skills/global/demo/download");
|
||||
case VERSIONED_DOWNLOAD ->
|
||||
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download");
|
||||
};
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder withCredentials(
|
||||
MockHttpServletRequestBuilder request,
|
||||
MixedCredentialState state) {
|
||||
return switch (state) {
|
||||
case SESSION_ONLY -> request.session(session());
|
||||
case SESSION_BASIC -> request.session(session())
|
||||
.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0");
|
||||
case BASIC_ONLY -> request.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0");
|
||||
case SESSION_VALID_BEARER -> withBearer(request.session(session()), createActiveToken());
|
||||
};
|
||||
}
|
||||
|
||||
private String expectedUserId(MixedCredentialState state) {
|
||||
return switch (state) {
|
||||
case SESSION_ONLY, SESSION_BASIC -> sessionUserId;
|
||||
case BASIC_ONLY -> null;
|
||||
case SESSION_VALID_BEARER -> userId;
|
||||
};
|
||||
}
|
||||
|
||||
private void assertProjectedUser(EndpointCase endpoint, String expectedUserId) {
|
||||
if (endpoint == EndpointCase.SEARCH) {
|
||||
ArgumentCaptor<String> userCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(cliSkillAppService).search(any(), anyInt(), userCaptor.capture(), any());
|
||||
assertEquals(expectedUserId, userCaptor.getValue());
|
||||
return;
|
||||
}
|
||||
if (endpoint == EndpointCase.RESOLVE) {
|
||||
ArgumentCaptor<String> userCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(cliSkillAppService).resolve(anyString(), anyString(), any(), userCaptor.capture(), any());
|
||||
assertEquals(expectedUserId, userCaptor.getValue());
|
||||
return;
|
||||
}
|
||||
|
||||
ArgumentCaptor<HttpServletRequest> requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class);
|
||||
if (endpoint == EndpointCase.LATEST_DOWNLOAD) {
|
||||
verify(cliSkillAppService).downloadLatest(anyString(), anyString(), requestCaptor.capture());
|
||||
} else {
|
||||
verify(cliSkillAppService).downloadVersion(
|
||||
anyString(), anyString(), anyString(), requestCaptor.capture());
|
||||
}
|
||||
assertEquals(expectedUserId, requestCaptor.getValue().getAttribute("userId"));
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder withBearer(
|
||||
|
|
@ -310,9 +438,24 @@ class CliTokenLifecycleSecurityIntegrationTest {
|
|||
userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]");
|
||||
}
|
||||
|
||||
private MockHttpSession session() {
|
||||
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
|
||||
securityContext.setAuthentication(sessionAuthentication());
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
session.setAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
||||
securityContext);
|
||||
return session;
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken sessionAuthentication() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
userId, "Session User", userId + "@example.com", "", "session", Set.of("USER"));
|
||||
sessionUserId,
|
||||
"Session User",
|
||||
sessionUserId + "@example.com",
|
||||
"",
|
||||
"session",
|
||||
Set.of("USER"));
|
||||
return new UsernamePasswordAuthenticationToken(principal, null, List.of());
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue