diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 4c2edd138..a8951b9e4 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -16,6 +16,7 @@ ┌─────────────────────────────┐ │ Layer 1: OAuth2 Login │ Spring Security OAuth2 Client │ (一期 GitHub,可扩展) │ 授权码模式 (Authorization Code) +│ Layer 1b: Session Bootstrap│ 显式被动会话引导(默认关闭) └─────────────┬───────────────┘ │ OAuth2User ▼ @@ -141,7 +142,78 @@ AuthenticationSuccessHandler: ② 重定向到前端页面 (可配置的 redirect_uri) ``` -### 3.1 Spring Security 配置要点 +### 3.1 统一 Session 建立约束 + +所有 Web 登录入口都必须通过统一的 `PlatformSessionService` 建立登录态,包括: + +- 本地用户名密码登录 +- OAuth 登录成功回调 +- `POST /api/v1/auth/direct/login` +- `POST /api/v1/auth/session/bootstrap` +- 本地开发态 `MockAuthFilter` + +统一约束如下: + +- 统一写入 `platformPrincipal` +- 统一写入 `SPRING_SECURITY_CONTEXT` +- 统一通过 `HttpSession` 持久化,确保 Spring Session Redis 能无差别接管 +- 交互式登录默认调用 `changeSessionId()`,降低 session fixation 风险 +- 已由 Spring Security 完成认证的入口可以复用现有 `Authentication`,避免重复构造认证结果 + +这意味着未来私有版新增企业 SSO provider 时,只能扩展认证来源本身,不能绕开统一的 session 建立服务直接操作 Session。 + +## 3.3 Session Bootstrap 扩展点 + +为了兼容未来私有部署中的企业 SSO 被动登录,开源版预留显式会话引导协议: + +- 接口:`POST /api/v1/auth/session/bootstrap` +- 用途:前端在同域场景下显式触发一次“读取外部会话并尝试换取 skillhub Session”的流程 +- 默认状态:关闭,开源版不提供任何 `PassiveSessionAuthenticator` 实现 +- 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度 + +扩展接口如下: + +```java +public interface PassiveSessionAuthenticator { + String providerCode(); + Optional authenticate(HttpServletRequest request); +} +``` + +约束如下: + +- `authenticate()` 只负责验证外部被动会话并返回平台登录所需主体 +- 是否允许启用该入口由 `skillhub.auth.session-bootstrap.enabled` 控制,默认 `false` +- 未启用时接口返回 `403` +- 启用但 provider 不受支持时返回 `400` +- 启用但请求中不存在有效外部会话时返回 `401` +- 成功时建立标准 Spring Security Session,并返回与 `/api/v1/auth/me` 一致的用户结构 + +## 3.4 Direct Authentication 扩展点 + +为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象: + +```java +public interface DirectAuthProvider { + String providerCode(); + PlatformPrincipal authenticate(DirectAuthRequest request); +} +``` + +对应公共协议: + +- `POST /api/v1/auth/direct/login` + +约束如下: + +- 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制 +- 关闭时返回 `403` +- provider 不受支持时返回 `400` +- provider 认证失败时沿用 provider 自身的认证异常语义 +- 成功时建立标准 Session,并返回与 `/api/v1/auth/me` 一致的用户结构 +- 现有 `/api/v1/auth/local/login` 保持不变,兼容层只是新增可选入口 + +### 3.5 Spring Security 配置要点 ```java @Configuration @@ -168,7 +240,7 @@ public class SecurityConfig { } ``` -### 3.2 OAuth2 Provider 扩展设计 +### 3.6 OAuth2 Provider 扩展设计 一期只实现 GitHub,但架构支持后续扩展: @@ -417,6 +489,7 @@ Session 中存储以下字段: 统一约束: - `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。 +- `/api/v1/auth/session/bootstrap` 也必须遵守同一统一响应结构。 - `msg` 必须走 Spring Boot 标准 `MessageSource` i18n 机制。 - locale 必须通过请求上下文自动获取,不在 controller 中显式传递。 - 认证失败返回 `401`,但 JSON 外层结构仍保持一致,例如 `{"code":401,"msg":"需要先登录","data":null,...}`。 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index b9a8afcf8..6e1da75cc 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -95,7 +95,7 @@ Public API 的可见性规则: - `parsedMetadataJson`:`SKILL.md` frontmatter 的完整 JSON 序列化结果 - `manifestJson`:版本文件清单摘要 JSON -## 7.2 Auth API(OAuth2 登录相关) +## 7.2 Auth API(登录与会话相关) | 方法 | 路径 | 说明 | |------|------|------| @@ -104,6 +104,9 @@ Public API 的可见性规则: | GET | `/api/v1/auth/me` | 当前用户信息(未登录返回 401) | | POST | `/api/v1/auth/logout` | 登出(清除 Session) | | GET | `/api/v1/auth/providers` | 可用的 OAuth Provider 列表(前端渲染登录按钮用) | +| GET | `/api/v1/auth/methods` | 统一登录方式目录(密码/OAuth/direct/bootstrap 元数据) | +| POST | `/api/v1/auth/direct/login` | 显式走直连认证 provider 的兼容登录入口(默认关闭) | +| POST | `/api/v1/auth/session/bootstrap` | 显式尝试用外部被动会话换取 skillhub Session(默认关闭) | `/api/v1/auth/providers` 响应示例: @@ -121,6 +124,74 @@ Public API 的可见性规则: 前端根据此接口动态渲染登录按钮,新增 Provider 无需改前端代码。 +`/api/v1/auth/methods` 返回统一登录方式目录。典型项包括: + +- `PASSWORD`:现有本地账号密码登录 +- `OAUTH_REDIRECT`:OAuth 跳转登录 +- `DIRECT_PASSWORD`:默认关闭的直连认证兼容入口 +- `SESSION_BOOTSTRAP`:默认关闭的被动会话引导入口 + +示例: + +```json +{ + "code": 0, + "msg": "获取成功", + "data": [ + { + "id": "local-password", + "methodType": "PASSWORD", + "provider": "local", + "displayName": "Local Account", + "actionUrl": "/api/v1/auth/local/login" + }, + { + "id": "oauth-github", + "methodType": "OAUTH_REDIRECT", + "provider": "github", + "displayName": "GitHub", + "actionUrl": "/oauth2/authorization/github" + } + ], + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" +} +``` + +`/api/v1/auth/session/bootstrap` 请求示例: + +```json +{ + "provider": "private-sso" +} +``` + +`/api/v1/auth/session/bootstrap` 协议约束: + +- 开源版默认关闭,需显式开启 `skillhub.auth.session-bootstrap.enabled=true` +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 外部会话不存在或校验失败时返回 `401` +- 成功时返回与 `/api/v1/auth/me` 相同的用户结构,并建立标准 Session + +`/api/v1/auth/direct/login` 请求示例: + +```json +{ + "provider": "private-sso", + "username": "alice", + "password": "secret" +} +``` + +`/api/v1/auth/direct/login` 协议约束: + +- 开源版默认关闭,需显式开启 `skillhub.auth.direct.enabled=true` +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 成功时返回与 `/api/v1/auth/me` 相同的用户结构,并建立标准 Session +- `/api/v1/auth/local/login` 继续保留,作为现有本地账号入口 + ## 7.3 Authenticated API(需登录) | 方法 | 路径 | 说明 | diff --git a/docs/08-frontend-architecture.md b/docs/08-frontend-architecture.md index 6b243f822..cda7e19a4 100644 --- a/docs/08-frontend-architecture.md +++ b/docs/08-frontend-architecture.md @@ -107,7 +107,39 @@ window.location.href = "/oauth2/authorization/github" 2. 跳转到对应的 `authorizationUrl` 3. 回调后通过 `/api/v1/auth/me` 检测登录态 -### 4.2 登录态检测 +### 4.2 预留的被动会话引导 + +为未来私有部署下的企业 SSO 兼容,前端可在登录页或应用初始化阶段显式调用: + +- `POST /api/v1/auth/session/bootstrap` + +该接口在开源版默认关闭;私有版启用后,前端可在检测到用户未登录时主动调用一次,以尝试将外部 SSO Cookie 换成 skillhub Session。该流程必须保持显式触发,不默认依赖全局透明拦截器。 + +前端兼容接入层约束如下: + +- 默认不启用,运行时配置不打开时,登录页和全局行为与开源版完全一致 +- 账号密码登录兼容层与被动会话兼容层相互独立,可单独启用 +- 启用后,登录页会出现一个“企业 SSO”兼容入口 +- 启用密码兼容层后,登录页账号密码表单会改为调用通用直连认证接口 +- 前端应优先消费 `/api/v1/auth/methods` 作为统一登录方式目录;`/api/v1/auth/providers` 仅保留兼容 +- 可选自动尝试,但仍限定在登录页内执行,不在全站每次匿名访问时自动探测 +- bootstrap 失败时应静默回退到现有本地登录和 OAuth 登录,不打断正常流程 + +前端运行时配置项: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + +推荐策略: + +- 私有版密码直连:`auth_direct_enabled=true`,`auth_direct_provider=private-sso` +- 私有版初期:`enabled=true`,`provider=private-sso`,`auto=false` +- 验证稳定后:再评估是否切到 `auto=true` + +### 4.3 登录态检测 ``` 页面加载 → GET /api/v1/auth/me diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 8cb8110dd..52e4849ee 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -158,6 +158,28 @@ docker compose --env-file .env.release -f compose.release.yml up -d ## 7 配置管理 +前端运行时配置通过 `web/runtime-config.js.template` 注入。与认证兼容层相关的新变量如下: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` + - 是否在前端打开账号密码兼容接入层 + - 默认应为 `false` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` + - 前端调用 `/api/v1/auth/direct/login` 时使用的 provider,例如 `private-sso` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` + - 是否在前端打开企业 SSO 被动会话兼容入口 + - 默认应为 `false` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` + - 前端调用 `/api/v1/auth/session/bootstrap` 时使用的 provider,例如 `private-sso` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + - 是否在登录页加载后自动尝试一次 bootstrap + - 建议私有版初期保持 `false` + +注意: + +- 前端密码兼容层打开之前,后端仍必须同步打开 `skillhub.auth.direct.enabled=true` +- 前端开关打开之前,后端仍必须同步打开 `skillhub.auth.session-bootstrap.enabled=true` +- 前后端任一侧未开启,都不会破坏原有登录方式;只会使该兼容入口不可用或不显示 + 开发环境: - 本地命令与 `docker-compose.yml` diff --git a/docs/11-auth-extensibility-and-private-sso.md b/docs/11-auth-extensibility-and-private-sso.md new file mode 100644 index 000000000..4158f8f8c --- /dev/null +++ b/docs/11-auth-extensibility-and-private-sso.md @@ -0,0 +1,142 @@ +# 认证扩展与私有 SSO 兼容设计 + +## 1. 目标 + +在不影响当前开源版 OAuth 和本地账号登录能力的前提下,为未来私有仓库接入企业 SSO 预留稳定扩展点,并把代码差异控制在 provider 实现层和少量配置层。 + +## 2. 已确认约束 + +- 私有 SSO 能提供稳定唯一 UID +- 用户名密码校验与 Cookie 会话校验都会返回同一稳定 UID +- 生产部署预期为 `skill.xxx.com` 与 `sso.xxx.com` +- 私有版可通过后端内部接口/RPC 代调用 SSO 校验用户名密码 +- 首次 SSO 登录自动创建 skillhub 账号 +- 不做账号合并设计,不依赖 email +- 登出联动可保留扩展点,但不是近期目标 + +## 3. 开源版兼容策略 + +### 3.1 不改变现有主链路 + +- 现有 OAuth 登录流程保持不变 +- 现有本地用户名密码登录保持不变 +- 现有 `/api/v1/auth/providers` 协议保持不变 +- 不在开源版中引入私有 SSO 的真实实现 + +### 3.2 新增的公共扩展协议 + +开源版新增显式被动会话引导接口: + +- `POST /api/v1/auth/session/bootstrap` + +请求: + +```json +{ + "provider": "private-sso" +} +``` + +行为约束: + +- 默认关闭,由 `skillhub.auth.session-bootstrap.enabled=false` 控制 +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 外部会话校验失败时返回 `401` +- 成功时建立 skillhub Session,并返回当前用户信息 + +同时新增默认关闭的直连认证兼容接口: + +- `POST /api/v1/auth/direct/login` + +请求: + +```json +{ + "provider": "private-sso", + "username": "alice", + "password": "secret" +} +``` + +行为约束: + +- 默认关闭,由 `skillhub.auth.direct.enabled=false` 控制 +- 关闭时返回 `403` +- provider 不存在时返回 `400` +- 成功时建立 skillhub Session,并返回当前用户信息 +- 开源版仍保留原始 `/api/v1/auth/local/login` + +### 3.3 代码级扩展点 + +```java +public interface PassiveSessionAuthenticator { + String providerCode(); + Optional authenticate(HttpServletRequest request); +} +``` + +```java +public interface DirectAuthProvider { + String providerCode(); + PlatformPrincipal authenticate(DirectAuthRequest request); +} +``` + +私有版只需要新增实现,例如: + +- `private-sso-cookie`:读取共享 Cookie 并向 SSO 校验 +- 后续如果需要,也可以补“用户名密码直连认证 provider”扩展点 + +## 4. 本轮已落地内容 + +- 新增 `PassiveSessionAuthenticator` SPI +- 新增 `DirectAuthProvider` SPI +- 新增统一会话建立服务 `PlatformSessionService` +- 新增 `POST /api/v1/auth/session/bootstrap` 协议 +- 新增 `POST /api/v1/auth/direct/login` 协议 +- 新增 `skillhub.auth.direct.enabled` 开关,默认关闭 +- 新增 `skillhub.auth.session-bootstrap.enabled` 开关,默认关闭 +- 前端新增基于运行时配置的账号密码兼容接入层 +- 前端新增基于运行时配置的被动会话兼容入口 +- 前端新增显式按钮和可选自动尝试逻辑,默认都不启用 +- 增加 controller 集成测试,验证: + - 默认关闭时不会影响现有系统 + - 启用并提供 authenticator 时可以建立 skillhub Session + +统一会话建立约束: + +- 本地登录、OAuth 成功回调、direct auth、session bootstrap、mock 登录旁路都走 `PlatformSessionService` +- 会话写入统一依赖 `HttpSession` 属性:`platformPrincipal` 与 `SPRING_SECURITY_CONTEXT` +- 因此在生产环境启用 Spring Session Redis 时,不需要为不同登录方式分别处理 Session 序列化或存储逻辑 +- 交互式登录默认轮换 session id;OAuth 这类已在 Spring Security 认证链中的流程复用现有 `Authentication` + +前端运行时配置: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + +使用方式: + +1. 若要做密码直连,后端启用 `skillhub.auth.direct.enabled=true` +2. 私有版提供 `DirectAuthProvider` 实现 +3. 前端设置 `SKILLHUB_WEB_AUTH_DIRECT_*` +4. 若要做被动会话,后端启用 `skillhub.auth.session-bootstrap.enabled=true` +5. 私有版提供 `PassiveSessionAuthenticator` 实现 +6. 前端设置 bootstrap provider 和开关 +7. 登录页显示兼容入口,或在配置允许时自动尝试一次 bootstrap + +## 5. 后续建议 + +- 私有版实现 `DirectAuthProvider` 和 / 或 `PassiveSessionAuthenticator` 时,只扩展 provider 层,不复制 session 建立逻辑 +- 私有版优先采用显式 bootstrap,而不是透明全局拦截器自动登录 +- 如后续需要登出联动,只通过 `LogoutPropagationHandler` 扩展,不改动现有主登出链路 + +## 6. 实施手册 + +更详细的私有 SSO 接入步骤、最佳实践、测试矩阵和给后续 coding agent 的执行约束,见: + +- [12-private-sso-integration-playbook.md](/Users/xudongsun/github/skillhub/docs/12-private-sso-integration-playbook.md) diff --git a/docs/12-private-sso-integration-playbook.md b/docs/12-private-sso-integration-playbook.md new file mode 100644 index 000000000..c7fc7dda2 --- /dev/null +++ b/docs/12-private-sso-integration-playbook.md @@ -0,0 +1,437 @@ +# 私有 SSO 接入兼容层实施手册 + +## 1. 文档目的 + +本文档面向两类读者: + +- 后续在私有仓库中接入企业 SSO 的开发者 +- 需要基于当前开源版兼容层继续开发的 coding agent + +本文档不是认证架构总览,而是实施手册。目标是让后续执行者在不了解全部历史上下文的情况下,也能基于当前成果直接开始接入工作,并且尽量把私有仓库与开源仓库的差异控制在 provider 实现层和少量配置层。 + +相关文档: + +- [03-authentication-design.md](/Users/xudongsun/github/skillhub/docs/03-authentication-design.md) +- [06-api-design.md](/Users/xudongsun/github/skillhub/docs/06-api-design.md) +- [08-frontend-architecture.md](/Users/xudongsun/github/skillhub/docs/08-frontend-architecture.md) +- [11-auth-extensibility-and-private-sso.md](/Users/xudongsun/github/skillhub/docs/11-auth-extensibility-and-private-sso.md) + +## 2. 当前上下文与已确认约束 + +本轮改造的真实目标不是在开源版里实现私有 SSO,而是先把开源版前后端改造成一个稳定的兼容接入层。 + +已经确认的业务前提如下: + +- 私有 SSO 能返回稳定且唯一的 UID +- 用户名密码校验接口与基于 Cookie 的会话校验接口都返回同一个 UID +- SkillHub 私有版与私有 SSO 会部署在统一主域下,例如 `skill.xxx.com` 与 `sso.xxx.com` +- 私有版可以通过内部接口或 RPC 调用 SSO 的用户名密码校验能力 +- 首次 SSO 登录自动创建 SkillHub 账号 +- 不考虑账号合并 +- 不依赖 email 字段 +- 不要求联动登出,但可保留低优先级扩展点 + +这意味着后续私有 SSO 的正确接入方式是: + +- 把 SSO 建模为新的认证来源 `private-sso` +- 用 `providerCode + subject` 表示外部身份,其中 `subject` 就是 SSO UID +- 复用当前平台的统一 Session 建立逻辑,而不是再造一套登录态机制 + +## 3. 当前兼容层已经提供了什么 + +### 3.1 后端扩展点 + +当前开源版已经提供以下后端兼容能力: + +- `DirectAuthProvider` + - 用于“前端收集用户名密码,后端调用外部系统校验”的模式 +- `PassiveSessionAuthenticator` + - 用于“浏览器自动带上 SSO Cookie,后端读取请求并向 SSO 校验”的模式 +- `PlatformSessionService` + - 用于统一建立 SkillHub Web Session +- `LogoutPropagationHandler` + - 用于未来低优先级登出联动 + +关键代码位置: + +- [DirectAuthProvider.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java) +- [PassiveSessionAuthenticator.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java) +- [PlatformSessionService.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java) + +### 3.2 后端公共协议 + +当前开源版已经提供以下兼容协议: + +- `POST /api/v1/auth/direct/login` +- `POST /api/v1/auth/session/bootstrap` +- `GET /api/v1/auth/methods` + +这些协议的设计原则如下: + +- 默认关闭 +- 默认没有私有 SSO 实现 +- 启用后由 provider 扩展驱动 +- 成功后统一建立标准 Spring Security Session +- 不替换现有 `/api/v1/auth/local/login` +- 不替换现有 OAuth 登录 + +### 3.3 前端兼容层 + +当前开源版前端已经支持通过运行时配置开启兼容入口: + +- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` +- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER` +- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO` + +前端设计原则如下: + +- 默认不启用任何私有登录入口 +- 开启后通过兼容层切换,不破坏现有登录页默认行为 +- 优先走统一目录接口 `/api/v1/auth/methods` +- 被动会话登录优先使用显式 bootstrap,而不是页面加载时偷偷尝试多次 + +## 4. 私有 SSO 的推荐接入方案 + +### 4.1 推荐总策略 + +最佳实践不是只选一种方式,而是同时支持两条链路: + +1. 主路径:`DirectAuthProvider` + - 登录页展示企业 SSO 用户名密码表单 + - 后端通过内部接口或 RPC 调用私有 SSO 校验 + - 校验成功后给用户建立 SkillHub Session + +2. 补充路径:`PassiveSessionAuthenticator` + - 当用户已经在 SSO 系统登录过,并且浏览器会自动带上共享 Cookie 时 + - 登录页允许用户主动点击“从企业 SSO 登录” + - 或在非常谨慎的前提下自动尝试一次 bootstrap + +这样做的理由: + +- 覆盖“尚未登录 SSO”和“已登录 SSO”两种用户状态 +- 不依赖浏览器一定已持有 Cookie +- 不把所有登录成功率押在 Cookie 域、SameSite、过期策略等细节上 +- 不改变开源版原始登录逻辑 + +### 4.2 不推荐的做法 + +以下做法不建议在私有版采用: + +- 在全局 servlet filter 中对所有匿名请求自动尝试 SSO 登录 +- 直接在 controller、filter 或 provider 里手写 `HttpSession` 和 `SecurityContext` 逻辑 +- 把私有 SSO 的 UID 映射成临时整数 ID 再作为用户主标识 +- 按 email 自动合并账号 +- 让前端直接调用私有 SSO 的内部校验接口 +- 为私有版新增一整套与开源版平行的“私有登录 session 机制” + +## 5. 私有版最小差异实施方案 + +### 5.1 后端应新增什么 + +私有仓库建议只新增以下实现类,不改主链路: + +1. 一个 `DirectAuthProvider` 实现 +2. 一个 `PassiveSessionAuthenticator` 实现 +3. 可选的 `LogoutPropagationHandler` 实现 +4. 私有配置属性类或私有配置项 +5. 若 SSO 返回的是外部 UID 而不是现成平台用户,需要补充“根据 SSO UID 查询或创建平台用户”的私有服务 + +建议命名示例: + +- `PrivateSsoDirectAuthProvider` +- `PrivateSsoPassiveSessionAuthenticator` +- `PrivateSsoLogoutPropagationHandler` +- `PrivateSsoProperties` +- `PrivateSsoIdentityService` + +不建议修改这些公共类的职责: + +- `PlatformSessionService` +- `LocalAuthController` +- `AuthController` +- `SecurityConfig` + +### 5.2 后端建议实现步骤 + +#### 步骤 1:定义 provider code + +私有版统一使用稳定 provider code: + +```text +private-sso +``` + +要求: + +- `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值 +- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code + +#### 步骤 2:封装 SSO 客户端 + +不要在 provider 实现里直接散落 HTTP 或 RPC 调用。建议先抽一层私有客户端: + +```java +public interface PrivateSsoClient { + PrivateSsoUser verifyPassword(String username, String password); + Optional verifySession(HttpServletRequest request); +} +``` + +其中 `PrivateSsoUser` 至少应包含: + +- `uid` +- `username` +- `displayName` + +最佳实践: + +- 所有超时、重试、日志脱敏、错误码翻译都放在客户端层 +- provider 层只负责把外部结果映射成平台所需的身份对象 +- 禁止记录明文密码 + +#### 步骤 3:实现用户映射服务 + +私有 SSO 不依赖 email,也不做账号合并,因此建议私有版实现一个专用服务: + +```java +public interface PrivateSsoIdentityService { + PlatformPrincipal resolveOrCreate(PrivateSsoUser ssoUser); +} +``` + +推荐逻辑: + +1. 按 `providerCode=private-sso` 和 `subject=ssoUid` 查现有绑定 +2. 若已存在,加载对应平台用户 +3. 若不存在,则自动创建平台用户 +4. 创建新的身份绑定 +5. 返回 `PlatformPrincipal` + +要求: + +- 自动创建出的用户默认应是 `ACTIVE` +- 不要尝试和现有本地账号或 OAuth 账号按 email 合并 + +#### 步骤 4:实现 `DirectAuthProvider` + +伪代码如下: + +```java +@Component +public class PrivateSsoDirectAuthProvider implements DirectAuthProvider { + + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + PrivateSsoUser ssoUser = privateSsoClient.verifyPassword( + request.username(), + request.password() + ); + return privateSsoIdentityService.resolveOrCreate(ssoUser); + } +} +``` + +要求: + +- 只返回认证成功后的 `PlatformPrincipal` +- 不在这里建立 Session +- 不在这里写 `SecurityContext` + +#### 步骤 5:实现 `PassiveSessionAuthenticator` + +伪代码如下: + +```java +@Component +public class PrivateSsoPassiveSessionAuthenticator implements PassiveSessionAuthenticator { + + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(HttpServletRequest request) { + return privateSsoClient.verifySession(request) + .map(privateSsoIdentityService::resolveOrCreate); + } +} +``` + +要求: + +- 只消费当前请求已带上的 Cookie 或其他被动凭证 +- 不主动重定向到 SSO +- 不在这里自行创建 Session + +#### 步骤 6:开启配置 + +私有版部署时启用: + +```yaml +skillhub: + auth: + direct: + enabled: true + session-bootstrap: + enabled: true +``` + +建议: + +- 预发环境先只开 direct auth +- passive bootstrap 在确认 Cookie 域和 SameSite 行为可靠后再开启 + +## 6. 前端最佳实践 + +### 6.1 推荐的登录页策略 + +私有版推荐保留当前开源登录页结构,但增加企业 SSO 入口: + +- 保留 OAuth 按钮 +- 本地账号登录是否保留,由私有版自行决定 +- 增加企业 SSO 用户名密码表单,或将现有密码表单切换到 direct auth 兼容接口 +- 增加“从企业 SSO 登录”按钮,对应 `session/bootstrap` + +推荐优先级: + +1. 首先提供明确可见的企业用户名密码登录 +2. 其次提供“从企业 SSO 登录”按钮 +3. 最后才考虑自动 bootstrap + +### 6.2 自动 bootstrap 的使用建议 + +只有在以下条件同时满足时才建议开启 `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO=true`: + +- 已确认浏览器在 `skill.xxx.com` 下能稳定带上 SSO Cookie +- 失败时 UI 不会卡死或重复重试 +- 页面只会自动尝试一次 +- 前端不会因为自动尝试失败而阻断正常密码登录 + +如果以上条件不满足,建议只显示一个显式按钮,让用户主动触发。 + +### 6.3 前端禁止事项 + +- 不要把密码提交给非 SkillHub 后端地址 +- 不要在浏览器里解析或操作私有 SSO 内部 Cookie 细节 +- 不要把 bootstrap 失败当成页面级致命错误 + +## 7. Spring Session Redis 相关约束 + +当前平台的统一 Web 登录态是 Spring Session。 + +后续私有版继续接入时,必须遵守以下规则: + +- 所有成功登录都必须通过 `PlatformSessionService` +- 所有 Web 会话都通过 `HttpSession` 持久化 +- 不要手动维护第二份“私有 SSO session” +- 不要在 Redis 中自行定义另一套认证缓存结构来替代 Session + +当前统一服务会做的事: + +- 写入 `platformPrincipal` +- 写入 `SPRING_SECURITY_CONTEXT` +- 在交互式登录流程中轮换 session id + +## 8. 安全最佳实践 + +### 8.1 用户名密码直连场景 + +- SkillHub 后端与私有 SSO 之间必须走内网或可信 RPC +- 明文密码只允许存在于浏览器提交和后端调用 SSO 的瞬时链路中 +- 日志、埋点、异常信息中禁止出现密码 +- 对下游 SSO 调用应设置超时和熔断策略 + +### 8.2 Cookie 被动会话场景 + +- 必须先确认 Cookie 域、路径、SameSite、Secure 策略能满足 `skill.xxx.com` 使用 +- bootstrap 接口应保留 CSRF 防护 +- 失败时只返回认证失败,不泄露过多 Cookie 校验细节 +- 除非有明确产品要求,否则不要做无感知的全站自动登录 filter + +### 8.3 身份映射场景 + +- 只信任稳定 UID,不信任显示名作为主身份依据 +- 不按 email 合并 +- 不按 username 合并 + +## 9. 建议测试矩阵 + +### 9.1 后端单元测试 + +- `DirectAuthProvider` 成功认证 +- `DirectAuthProvider` 认证失败 +- `PassiveSessionAuthenticator` 在有效 Cookie 下成功返回主体 +- `PassiveSessionAuthenticator` 在无效 Cookie 下返回空或失败 +- `PrivateSsoIdentityService` 首次登录自动建号 +- `PrivateSsoIdentityService` 再次登录复用已有绑定 + +### 9.2 后端集成测试 + +- `POST /api/v1/auth/direct/login` 在开启配置后能建立 Session +- `POST /api/v1/auth/session/bootstrap` 在开启配置后能建立 Session +- 成功登录后 `/api/v1/auth/me` 返回正确用户 +- direct auth 与现有 `/api/v1/auth/local/login` 不互相影响 +- bootstrap 关闭时仍返回 `403` +- direct auth 关闭时仍返回 `403` + +### 9.3 前端测试 + +- 未开启运行时开关时,登录页与开源版默认行为一致 +- 开启 direct auth 后,密码表单请求走 `/api/v1/auth/direct/login` +- 开启 bootstrap 按钮后,点击能触发 bootstrap 请求 +- 自动 bootstrap 失败后,用户仍可正常使用其它登录入口 + +### 9.4 手工验收 + +- 已登录 SSO 的浏览器中,bootstrap 能成功建立 SkillHub 登录态 +- 未登录 SSO 的浏览器中,bootstrap 失败但不影响密码登录 +- direct auth 登录成功后,刷新页面仍保持登录态 +- 多 Pod 环境下,借助 Spring Session Redis,切换实例后 session 仍有效 + +## 10. 推荐开发顺序 + +如果后续在私有仓库中真正开始接入,建议按下面顺序推进: + +1. 实现 `PrivateSsoClient` +2. 实现 `PrivateSsoIdentityService` +3. 实现 `PrivateSsoDirectAuthProvider` +4. 先启用 `skillhub.auth.direct.enabled=true` +5. 前端接通 direct auth 入口并完成测试 +6. 再实现 `PrivateSsoPassiveSessionAuthenticator` +7. 确认 Cookie 作用域和浏览器行为 +8. 启用 `session-bootstrap` +9. 视需要决定是否开启自动 bootstrap + +## 11. 给 coding agent 的执行指令 + +如果后续由 AI 继续在私有仓库上完成接入,建议严格遵守以下执行规则: + +- 先读 [11-auth-extensibility-and-private-sso.md](/Users/xudongsun/github/skillhub/docs/11-auth-extensibility-and-private-sso.md) 和本文档 +- 不要重构现有公共认证主链路,除非发现明确 bug +- 私有 SSO 的具体实现优先写成 provider、authenticator、client、identity service +- 不要复制 `PlatformSessionService` 逻辑 +- 不要在多个 controller 或 filter 中重复写 Session 建立代码 +- 任何新增前端行为都必须保证运行时配置关闭时完全不影响开源版 +- 所有新增协议和运行时配置必须同步更新文档 +- 每完成一个阶段都跑后端测试;涉及前端改动时再补跑 `pnpm typecheck` 和 `pnpm build` + +## 12. 完成定义 + +当私有版 SSO 接入完成时,应满足以下标准: + +- 开源版默认登录方式仍然不变 +- 私有版只通过扩展点接入,没有复制一套独立登录架构 +- direct auth 可用 +- session bootstrap 可用 +- 首次 SSO 登录自动建号 +- 统一使用 Spring Session Redis 承载 Web 登录态 +- `/api/v1/auth/me`、RBAC、现有业务接口对登录来源无感知 +- 文档、配置、测试都完整 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthSessionBootstrapProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthSessionBootstrapProperties.java new file mode 100644 index 000000000..0bb4f6bf2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthSessionBootstrapProperties.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.session-bootstrap") +public class AuthSessionBootstrapProperties { + + /** + * Kept disabled in OSS by default. Private deployments can opt in explicitly. + */ + private boolean enabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DirectAuthProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DirectAuthProperties.java new file mode 100644 index 000000000..c1f883085 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DirectAuthProperties.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.direct") +public class DirectAuthProperties { + + /** + * Default disabled to keep OSS behavior unchanged. + */ + private boolean enabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index ae803aeb2..e44d3124d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -4,31 +4,42 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuthMeResponse; +import com.iflytek.skillhub.dto.AuthMethodResponse; import com.iflytek.skillhub.dto.AuthProviderResponse; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import com.iflytek.skillhub.dto.DirectLoginRequest; +import com.iflytek.skillhub.dto.SessionBootstrapRequest; +import com.iflytek.skillhub.service.AuthMethodCatalog; +import com.iflytek.skillhub.service.DirectAuthService; +import com.iflytek.skillhub.service.SessionBootstrapService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Comparator; import java.util.List; @RestController @RequestMapping("/api/v1/auth") public class AuthController extends BaseApiController { - private final OAuth2ClientProperties oAuth2ClientProperties; + private final AuthMethodCatalog authMethodCatalog; + private final SessionBootstrapService sessionBootstrapService; + private final DirectAuthService directAuthService; public AuthController(ApiResponseFactory responseFactory, - OAuth2ClientProperties oAuth2ClientProperties) { + AuthMethodCatalog authMethodCatalog, + SessionBootstrapService sessionBootstrapService, + DirectAuthService directAuthService) { super(responseFactory); - this.oAuth2ClientProperties = oAuth2ClientProperties; + this.authMethodCatalog = authMethodCatalog; + this.sessionBootstrapService = sessionBootstrapService; + this.directAuthService = directAuthService; } @GetMapping("/me") @@ -43,25 +54,38 @@ public ApiResponse me(@AuthenticationPrincipal PlatformPrincipal @GetMapping("/providers") public ApiResponse> providers( @RequestParam(name = "returnTo", required = false) String returnTo) { - String sanitizedReturnTo = com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); - List providers = new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream() - .sorted(Comparator.comparing(entry -> entry.getKey())) - .map(entry -> new AuthProviderResponse( - entry.getKey(), - entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() - ? entry.getValue().getClientName() - : entry.getKey(), - buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) - )) - .toList()); - return ok("response.success.read", providers); + return ok("response.success.read", authMethodCatalog.listOAuthProviders(returnTo)); } - private String buildAuthorizationUrl(String registrationId, String returnTo) { - String baseUrl = "/oauth2/authorization/" + registrationId; - if (returnTo == null) { - return baseUrl; - } - return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8); + @GetMapping("/methods") + public ApiResponse> methods( + @RequestParam(name = "returnTo", required = false) String returnTo) { + return ok("response.success.read", authMethodCatalog.listMethods(returnTo)); + } + + @PostMapping("/session/bootstrap") + public ApiResponse bootstrapSession(@Valid @RequestBody SessionBootstrapRequest request, + HttpServletRequest httpRequest) { + return ok( + "response.success.read", + AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest)) + ); } + + @PostMapping("/direct/login") + public ApiResponse directLogin(@Valid @RequestBody DirectLoginRequest request, + HttpServletRequest httpRequest) { + return ok( + "response.success.read", + AuthMeResponse.from( + directAuthService.authenticate( + request.provider(), + request.username(), + request.password(), + httpRequest + ) + ) + ); + } + } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 59494ab58..4aff530f9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.auth.local.LocalAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuthMeResponse; @@ -12,13 +13,7 @@ import com.iflytek.skillhub.metrics.SkillHubMetrics; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import java.util.List; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -30,13 +25,16 @@ public class LocalAuthController extends BaseApiController { private final LocalAuthService localAuthService; private final SkillHubMetrics skillHubMetrics; + private final PlatformSessionService platformSessionService; public LocalAuthController(ApiResponseFactory responseFactory, LocalAuthService localAuthService, - SkillHubMetrics skillHubMetrics) { + SkillHubMetrics skillHubMetrics, + PlatformSessionService platformSessionService) { super(responseFactory); this.localAuthService = localAuthService; this.skillHubMetrics = skillHubMetrics; + this.platformSessionService = platformSessionService; } @PostMapping("/register") @@ -44,7 +42,7 @@ public ApiResponse register(@Valid @RequestBody LocalRegisterReq HttpServletRequest httpRequest) { PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); skillHubMetrics.incrementUserRegister(); - establishSession(principal, httpRequest); + platformSessionService.establishSession(principal, httpRequest); return ok("response.success.created", AuthMeResponse.from(principal)); } @@ -59,7 +57,7 @@ public ApiResponse login(@Valid @RequestBody LocalLoginRequest r throw ex; } skillHubMetrics.recordLocalLogin(true); - establishSession(principal, httpRequest); + platformSessionService.establishSession(principal, httpRequest); return ok("response.success.read", AuthMeResponse.from(principal)); } @@ -72,16 +70,4 @@ public ApiResponse changePassword(@AuthenticationPrincipal PlatformPrincip localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword()); return ok("response.success.updated", null); } - - private void establishSession(PlatformPrincipal principal, HttpServletRequest request) { - var authorities = principal.platformRoles().stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .toList(); - var authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); - request.getSession(true).setAttribute("platformPrincipal", principal); - request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMethodResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMethodResponse.java new file mode 100644 index 000000000..143c59816 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMethodResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +public record AuthMethodResponse( + String id, + String methodType, + String provider, + String displayName, + String actionUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DirectLoginRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DirectLoginRequest.java new file mode 100644 index 000000000..82b8626f7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DirectLoginRequest.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record DirectLoginRequest( + @NotBlank(message = "认证提供方不能为空") + String provider, + @NotBlank(message = "用户名不能为空") + String username, + @NotBlank(message = "密码不能为空") + String password +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SessionBootstrapRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SessionBootstrapRequest.java new file mode 100644 index 000000000..ee419b337 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SessionBootstrapRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SessionBootstrapRequest( + @NotBlank(message = "认证提供方不能为空") + String provider +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java new file mode 100644 index 000000000..eef266bc7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public class BadRequestException extends LocalizedException { + + public BadRequestException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } + + @Override + public HttpStatus status() { + return HttpStatus.BAD_REQUEST; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java new file mode 100644 index 000000000..6c60cb102 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/ForbiddenException.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.exception; + +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends LocalizedException { + + public ForbiddenException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } + + @Override + public HttpStatus status() { + return HttpStatus.FORBIDDEN; + } +} 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 new file mode 100644 index 000000000..2ec06f5fb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -0,0 +1,111 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.config.DirectAuthProperties; +import com.iflytek.skillhub.dto.AuthMethodResponse; +import com.iflytek.skillhub.dto.AuthProviderResponse; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.stereotype.Service; + +@Service +public class AuthMethodCatalog { + + private final OAuth2ClientProperties oAuth2ClientProperties; + private final DirectAuthProperties directAuthProperties; + private final AuthSessionBootstrapProperties sessionBootstrapProperties; + private final List directAuthProviders; + private final List passiveSessionAuthenticators; + + public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties, + DirectAuthProperties directAuthProperties, + AuthSessionBootstrapProperties sessionBootstrapProperties, + List directAuthProviders, + List passiveSessionAuthenticators) { + this.oAuth2ClientProperties = oAuth2ClientProperties; + this.directAuthProperties = directAuthProperties; + this.sessionBootstrapProperties = sessionBootstrapProperties; + this.directAuthProviders = directAuthProviders; + this.passiveSessionAuthenticators = passiveSessionAuthenticators; + } + + public List listOAuthProviders(String returnTo) { + String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); + return new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getKey())) + .map(entry -> new AuthProviderResponse( + entry.getKey(), + entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() + ? entry.getValue().getClientName() + : entry.getKey(), + buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + )) + .toList()); + } + + public List listMethods(String returnTo) { + String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); + List methods = new ArrayList<>(); + + methods.add(new AuthMethodResponse( + "local-password", + "PASSWORD", + "local", + "Local Account", + "/api/v1/auth/local/login" + )); + + oAuth2ClientProperties.getRegistration().entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getKey())) + .forEach(entry -> methods.add(new AuthMethodResponse( + "oauth-" + entry.getKey(), + "OAUTH_REDIRECT", + entry.getKey(), + entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() + ? entry.getValue().getClientName() + : entry.getKey(), + buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + ))); + + if (directAuthProperties.isEnabled()) { + directAuthProviders.stream() + .sorted(Comparator.comparing(DirectAuthProvider::providerCode)) + .forEach(provider -> methods.add(new AuthMethodResponse( + "direct-" + provider.providerCode(), + "DIRECT_PASSWORD", + provider.providerCode(), + provider.providerCode(), + "/api/v1/auth/direct/login" + ))); + } + + if (sessionBootstrapProperties.isEnabled()) { + passiveSessionAuthenticators.stream() + .sorted(Comparator.comparing(PassiveSessionAuthenticator::providerCode)) + .forEach(provider -> methods.add(new AuthMethodResponse( + "bootstrap-" + provider.providerCode(), + "SESSION_BOOTSTRAP", + provider.providerCode(), + provider.providerCode(), + "/api/v1/auth/session/bootstrap" + ))); + } + + return methods; + } + + private String buildAuthorizationUrl(String registrationId, String returnTo) { + String baseUrl = "/oauth2/authorization/" + registrationId; + 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/DirectAuthService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java new file mode 100644 index 000000000..3eb857951 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DirectAuthService.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.config.DirectAuthProperties; +import com.iflytek.skillhub.exception.BadRequestException; +import com.iflytek.skillhub.exception.ForbiddenException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.springframework.stereotype.Service; + +@Service +public class DirectAuthService { + + private final DirectAuthProperties properties; + private final Map providersByCode; + private final SessionBootstrapService sessionBootstrapService; + + public DirectAuthService(DirectAuthProperties properties, + List providers, + SessionBootstrapService sessionBootstrapService) { + this.properties = properties; + this.providersByCode = providers.stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + DirectAuthProvider::providerCode, + Function.identity() + )); + this.sessionBootstrapService = sessionBootstrapService; + } + + public PlatformPrincipal authenticate(String providerCode, + String username, + String password, + HttpServletRequest request) { + if (!properties.isEnabled()) { + throw new ForbiddenException("error.auth.direct.disabled"); + } + + DirectAuthProvider provider = providersByCode.get(providerCode); + if (provider == null) { + throw new BadRequestException("error.auth.direct.providerUnsupported", providerCode); + } + + PlatformPrincipal principal = provider.authenticate(new DirectAuthRequest(username, password)); + sessionBootstrapService.establishSession(principal, request); + return principal; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java new file mode 100644 index 000000000..f24606fa2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SessionBootstrapService.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.exception.BadRequestException; +import com.iflytek.skillhub.exception.ForbiddenException; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.springframework.stereotype.Service; + +@Service +public class SessionBootstrapService { + + private final AuthSessionBootstrapProperties properties; + private final Map authenticatorsByProvider; + private final PlatformSessionService platformSessionService; + + public SessionBootstrapService(AuthSessionBootstrapProperties properties, + List authenticators, + PlatformSessionService platformSessionService) { + this.properties = properties; + this.authenticatorsByProvider = authenticators.stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + PassiveSessionAuthenticator::providerCode, + Function.identity() + )); + this.platformSessionService = platformSessionService; + } + + public PlatformPrincipal bootstrap(String providerCode, HttpServletRequest request) { + if (!properties.isEnabled()) { + throw new ForbiddenException("error.auth.sessionBootstrap.disabled"); + } + + PassiveSessionAuthenticator authenticator = authenticatorsByProvider.get(providerCode); + if (authenticator == null) { + throw new BadRequestException("error.auth.sessionBootstrap.providerUnsupported", providerCode); + } + + PlatformPrincipal principal = authenticator.authenticate(request) + .orElseThrow(() -> new UnauthorizedException("error.auth.sessionBootstrap.notAuthenticated")); + platformSessionService.establishSession(principal, request); + return principal; + } + + public void establishSession(PlatformPrincipal principal, HttpServletRequest request) { + platformSessionService.establishSession(principal, request); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index cd55e8f19..c08d26bcd 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -59,6 +59,11 @@ spring: max-request-size: 100MB skillhub: + auth: + direct: + enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} + session-bootstrap: + enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 59782dabc..f672ff01d 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -17,6 +17,11 @@ validation.member.role.notNull=Role is required validation.token.name.notBlank=Token name cannot be blank error.auth.required=Authentication required +error.auth.direct.disabled=Direct authentication compatibility is disabled +error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0} +error.auth.sessionBootstrap.disabled=Session bootstrap is disabled +error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} +error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request error.forbidden=Forbidden error.rateLimit.exceeded=Rate limit exceeded diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 3946cbdc2..b56befd24 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -19,7 +19,9 @@ import static org.hamcrest.Matchers.hasItems; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -114,4 +116,41 @@ void providersShouldAppendReturnToWhenRequested() throws Exception { "/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish" ))); } + + @Test + void methodsShouldExposeStandardLoginCatalog() throws Exception { + mockMvc.perform(get("/api/v1/auth/methods").param("returnTo", "/dashboard/publish")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[*].id", hasItems("local-password", "oauth-github", "oauth-gitee"))) + .andExpect(jsonPath("$.data[?(@.id=='local-password')].methodType").value(hasItems("PASSWORD"))) + .andExpect(jsonPath("$.data[?(@.id=='oauth-github')].actionUrl") + .value(hasItems("/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish"))); + } + + @Test + void sessionBootstrapShouldBeForbiddenWhenFeatureIsDisabled() throws Exception { + mockMvc.perform(post("/api/v1/auth/session/bootstrap") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso"} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)) + .andExpect(jsonPath("$.msg").isNotEmpty()); + } + + @Test + void directLoginShouldBeForbiddenWhenFeatureIsDisabled() throws Exception { + mockMvc.perform(post("/api/v1/auth/direct/login") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso","username":"alice","password":"secret"} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)) + .andExpect(jsonPath("$.msg").isNotEmpty()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java new file mode 100644 index 000000000..e62981715 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -0,0 +1,84 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "skillhub.auth.direct.enabled=true" +}) +class DirectAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LocalAuthService localAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_direct_1", + "direct-user", + null, + null, + "local", + Set.of("USER") + ); + given(localAuthService.login("direct-user", "Abcd123!")).willReturn(principal); + given(namespaceMemberRepository.findByUserId("usr_direct_1")).willReturn(List.of()); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"local","username":"direct-user","password":"Abcd123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("usr_direct_1")) + .andReturn() + .getRequest() + .getSession(false); + + mockMvc.perform(get("/api/v1/auth/me").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("usr_direct_1")); + } + + @Test + void directLoginShouldRejectUnsupportedProvider() throws Exception { + mockMvc.perform(post("/api/v1/auth/direct/login") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso","username":"user","password":"pw"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java new file mode 100644 index 000000000..59d0746dc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "skillhub.auth.session-bootstrap.enabled=true" +}) +class SessionBootstrapControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception { + given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of()); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"private-sso"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("sso-user-1")) + .andExpect(jsonPath("$.data.displayName").value("Private SSO User")) + .andReturn() + .getRequest() + .getSession(false); + + mockMvc.perform(get("/api/v1/auth/me").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("sso-user-1")) + .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")); + } + + @Test + void sessionBootstrapShouldRejectUnsupportedProvider() throws Exception { + mockMvc.perform(post("/api/v1/auth/session/bootstrap") + .with(csrf()) + .contentType("application/json") + .content(""" + {"provider":"unknown"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @TestConfiguration + static class SessionBootstrapTestConfig { + + @Bean + PassiveSessionAuthenticator privateSsoAuthenticator() { + return new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.of(new PlatformPrincipal( + "sso-user-1", + "Private SSO User", + null, + null, + "private-sso", + Set.of("USER") + )); + } + }; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java new file mode 100644 index 000000000..72be3565f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.bootstrap; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * Extension point for establishing a SkillHub session from an external passive session, + * such as an SSO cookie already present on the request. + */ +public interface PassiveSessionAuthenticator { + + String providerCode(); + + Optional authenticate(HttpServletRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index fbaceb91f..5e56b6f62 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -72,7 +72,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/api/v1/health", "/api/v1/auth/providers", + "/api/v1/auth/methods", "/api/v1/auth/me", + "/api/v1/auth/session/bootstrap", + "/api/v1/auth/direct/login", "/api/v1/auth/local/**", "/api/v1/cli/auth/device/**", "/api/v1/cli/check", diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java new file mode 100644 index 000000000..7f62e53fc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.direct; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +/** + * Extension point for username/password style direct authentication sources. + */ +public interface DirectAuthProvider { + + String providerCode(); + + PlatformPrincipal authenticate(DirectAuthRequest request); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java new file mode 100644 index 000000000..83d3c65e7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.direct; + +public record DirectAuthRequest( + String username, + String password +) {} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java new file mode 100644 index 000000000..45f4d4ff4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.auth.direct; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import org.springframework.stereotype.Component; + +@Component +public class LocalDirectAuthProvider implements DirectAuthProvider { + + private final LocalAuthService localAuthService; + + public LocalDirectAuthProvider(LocalAuthService localAuthService) { + this.localAuthService = localAuthService; + } + + @Override + public String providerCode() { + return "local"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + return localAuthService.login(request.username(), request.password()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java index 30004856d..f702e66a5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import jakarta.servlet.FilterChain; @@ -10,8 +11,6 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -27,11 +26,14 @@ public class MockAuthFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; + private final PlatformSessionService platformSessionService; public MockAuthFilter(UserAccountRepository userRepo, - UserRoleBindingRepository roleBindingRepo) { + UserRoleBindingRepository roleBindingRepo, + PlatformSessionService platformSessionService) { this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; + this.platformSessionService = platformSessionService; } @Override @@ -50,12 +52,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse user.getId(), user.getDisplayName(), user.getEmail(), user.getAvatarUrl(), "mock", roles ); - var authorities = roles.stream() - .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) - .toList(); - var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - request.getSession().setAttribute("platformPrincipal", principal); + platformSessionService.establishSession(principal, request, false); }); } filterChain.doFilter(request, response); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index 9328d9058..9d210c009 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.oauth; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,7 +16,10 @@ @Component public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { - public OAuth2LoginSuccessHandler() { + private final PlatformSessionService platformSessionService; + + public OAuth2LoginSuccessHandler(PlatformSessionService platformSessionService) { + this.platformSessionService = platformSessionService; setDefaultTargetUrl(OAuthLoginRedirectSupport.DEFAULT_TARGET_URL); } @@ -25,7 +29,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) { PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal"); if (principal != null) { - request.getSession().setAttribute("platformPrincipal", principal); + platformSessionService.attachToAuthenticatedSession(principal, authentication, request); } } String returnTo = consumeReturnTo(request.getSession(false)); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java new file mode 100644 index 000000000..08a1b7f25 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.auth.session; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.stereotype.Service; + +@Service +public class PlatformSessionService { + + public void establishSession(PlatformPrincipal principal, HttpServletRequest request) { + establishSession(principal, request, true); + } + + public void establishSession(PlatformPrincipal principal, + HttpServletRequest request, + boolean rotateSessionId) { + var authorities = principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + persist(principal, authentication, request, rotateSessionId); + } + + public void attachToAuthenticatedSession(PlatformPrincipal principal, + Authentication authentication, + HttpServletRequest request) { + persist(principal, authentication, request, false); + } + + private void persist(PlatformPrincipal principal, + Authentication authentication, + HttpServletRequest request, + boolean rotateSessionId) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + request.getSession(true); + if (rotateSessionId) { + request.changeSessionId(); + } + request.getSession().setAttribute("platformPrincipal", principal); + request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index e6e87bb82..6f2cd2a18 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -9,6 +9,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import java.util.List; import java.util.Map; @@ -20,7 +21,7 @@ class OAuth2LoginHandlersTest { @Test void successHandler_redirectsToStoredReturnTo() throws Exception { - OAuth2LoginSuccessHandler handler = new OAuth2LoginSuccessHandler(); + OAuth2LoginSuccessHandler handler = new OAuth2LoginSuccessHandler(new com.iflytek.skillhub.auth.session.PlatformSessionService()); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); HttpSession session = request.getSession(true); @@ -39,6 +40,8 @@ void successHandler_redirectsToStoredReturnTo() throws Exception { assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish"); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); + assertThat(session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNotNull(); } @Test diff --git a/web/runtime-config.js.template b/web/runtime-config.js.template index 8eef1601b..1375a3805 100644 --- a/web/runtime-config.js.template +++ b/web/runtime-config.js.template @@ -1,4 +1,9 @@ window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: "${SKILLHUB_WEB_API_BASE_URL}", - appBaseUrl: "${SKILLHUB_PUBLIC_BASE_URL}" + appBaseUrl: "${SKILLHUB_PUBLIC_BASE_URL}", + authDirectEnabled: "${SKILLHUB_WEB_AUTH_DIRECT_ENABLED}", + authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}", + authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}", + authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}", + authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}" }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 94f549a6d..976a1f97d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -16,6 +16,7 @@ import type { AdminUser, AuditLogItem, SkillSummary, + AuthMethod, OAuthProvider, User, } from './types' @@ -26,6 +27,11 @@ export { ApiError } type RuntimeConfig = { apiBaseUrl?: string appBaseUrl?: string + authDirectEnabled?: string + authDirectProvider?: string + authSessionBootstrapEnabled?: string + authSessionBootstrapProvider?: string + authSessionBootstrapAuto?: string } declare global { @@ -45,6 +51,13 @@ function getApiBaseUrl(): string { return getRuntimeConfig().apiBaseUrl ?? '' } +function parseBooleanFlag(value: string | undefined): boolean { + if (!value) { + return false + } + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()) +} + const client = createClient({ baseUrl: getApiBaseUrl() }) function getCsrfToken(): string | null { @@ -106,6 +119,36 @@ export function getCsrfHeaders(headers?: HeadersInit): HeadersInit { return withCsrf(headers) } +export type SessionBootstrapRuntimeConfig = { + enabled: boolean + provider?: string + auto: boolean +} + +export type DirectAuthRuntimeConfig = { + enabled: boolean + provider?: string +} + +export function getDirectAuthRuntimeConfig(): DirectAuthRuntimeConfig { + const config = getRuntimeConfig() + const provider = config.authDirectProvider?.trim() + return { + enabled: parseBooleanFlag(config.authDirectEnabled) && !!provider, + provider: provider || undefined, + } +} + +export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfig { + const config = getRuntimeConfig() + const provider = config.authSessionBootstrapProvider?.trim() + return { + enabled: parseBooleanFlag(config.authSessionBootstrapEnabled) && !!provider, + provider: provider || undefined, + auto: parseBooleanFlag(config.authSessionBootstrapAuto), + } +} + type ApiEnvelope = { code: number msg: string @@ -195,6 +238,21 @@ export const authApi = { })) }, + async getMethods(returnTo?: string): Promise { + const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '' + const methods = await fetchJson(`/api/v1/auth/methods${query}`) + return methods + .filter((method) => method.id && method.methodType && method.provider && method.displayName && method.actionUrl) + .map((method) => ({ + ...method, + id: method.id, + methodType: method.methodType, + provider: method.provider, + displayName: method.displayName, + actionUrl: method.actionUrl, + })) + }, + async localLogin(request: LocalLoginRequest): Promise { return fetchJson('/api/v1/auth/local/login', { method: 'POST', @@ -234,6 +292,30 @@ export const authApi = { throw new Error(`HTTP ${response.status}`) } }, + + async bootstrapSession(provider: string): Promise { + return fetchJson('/api/v1/auth/session/bootstrap', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ provider }), + }) + }, + + async directLogin(provider: string, request: LocalLoginRequest): Promise { + return fetchJson('/api/v1/auth/direct/login', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + provider, + username: request.username, + password: request.password, + }), + }) + }, } export const accountApi = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3cd532b31..63ad78bb8 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,6 +15,14 @@ export type OAuthProvider = Omit & { id: number name: string diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index d075842d1..d1c3cbeb3 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { authApi } from '@/api/client' import { Button } from '@/shared/ui/button' -import type { OAuthProvider } from '@/api/types' +import type { AuthMethod } from '@/api/types' interface LoginButtonProps { returnTo?: string @@ -10,12 +10,12 @@ interface LoginButtonProps { export function LoginButton({ returnTo }: LoginButtonProps) { const { t } = useTranslation() - const { data, isLoading } = useQuery({ - queryKey: ['auth', 'providers', returnTo ?? ''], - queryFn: () => authApi.getProviders(returnTo), + const { data, isLoading } = useQuery({ + queryKey: ['auth', 'methods', returnTo ?? ''], + queryFn: () => authApi.getMethods(returnTo), }) - const providers = data ?? [] + const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT') if (isLoading) { return ( @@ -36,13 +36,13 @@ export function LoginButton({ returnTo }: LoginButtonProps) { className="w-full h-12 text-base" variant="outline" onClick={() => { - window.location.href = provider.authorizationUrl + window.location.href = provider.actionUrl }} > - {t('loginButton.loginWith', { name: provider.name })} + {t('loginButton.loginWith', { name: provider.displayName })} ))} diff --git a/web/src/features/auth/session-bootstrap-entry.tsx b/web/src/features/auth/session-bootstrap-entry.tsx new file mode 100644 index 000000000..98ab85af1 --- /dev/null +++ b/web/src/features/auth/session-bootstrap-entry.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { ApiError, getSessionBootstrapRuntimeConfig } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { useSessionBootstrap } from './use-session-bootstrap' + +interface SessionBootstrapEntryProps { + onAuthenticated: () => Promise +} + +export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntryProps) { + const { t } = useTranslation() + const config = getSessionBootstrapRuntimeConfig() + const bootstrapMutation = useSessionBootstrap() + const attemptedRef = useRef(false) + + useEffect(() => { + if (!config.enabled || !config.provider || !config.auto || attemptedRef.current) { + return + } + attemptedRef.current = true + void bootstrapMutation.mutateAsync(config.provider, { + onSuccess: async () => { + await onAuthenticated() + }, + onError: () => { + // Fallback to normal login options without surfacing a global auth error. + }, + }) + }, [bootstrapMutation, config.auto, config.enabled, config.provider, onAuthenticated]) + + if (!config.enabled || !config.provider) { + return null + } + + const manualError = bootstrapMutation.error instanceof ApiError + && bootstrapMutation.error.status !== 401 + && bootstrapMutation.error.status !== 403 + ? bootstrapMutation.error.message + : null + + return ( +
+
+

+ {t('login.enterpriseSsoTitle')} +

+

+ {config.auto ? t('login.enterpriseSsoAutoHint') : t('login.enterpriseSsoHint')} +

+
+ + + + {manualError ? ( +

{manualError}

+ ) : null} +
+ ) +} diff --git a/web/src/features/auth/use-password-login.ts b/web/src/features/auth/use-password-login.ts new file mode 100644 index 000000000..106d92385 --- /dev/null +++ b/web/src/features/auth/use-password-login.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { authApi, getDirectAuthRuntimeConfig } from '@/api/client' +import type { LocalLoginRequest, User } from '@/api/types' + +export function usePasswordLogin() { + const queryClient = useQueryClient() + const directAuthConfig = getDirectAuthRuntimeConfig() + + return useMutation({ + mutationFn: (request: LocalLoginRequest) => { + if (directAuthConfig.enabled && directAuthConfig.provider) { + return authApi.directLogin(directAuthConfig.provider, request) + } + return authApi.localLogin(request) + }, + onSuccess: (user) => { + queryClient.setQueryData(['auth', 'me'], user) + }, + }) +} diff --git a/web/src/features/auth/use-session-bootstrap.ts b/web/src/features/auth/use-session-bootstrap.ts new file mode 100644 index 000000000..eb9b7d71f --- /dev/null +++ b/web/src/features/auth/use-session-bootstrap.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { User } from '@/api/types' + +export function useSessionBootstrap() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (provider) => authApi.bootstrapSession(provider), + onSuccess: (user) => { + queryClient.setQueryData(['auth', 'me'], user) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6f4261f3c..892b8648a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -115,6 +115,12 @@ "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", + "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to the configured direct authentication provider instead of the fixed local account endpoint.", + "enterpriseSsoTitle": "Enterprise SSO", + "enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has an enterprise SSO session, you can try establishing a SkillHub session directly.", + "enterpriseSsoAutoHint": "This deployment has automatic enterprise SSO probing enabled. If it does not succeed, you can continue with the standard login methods.", + "enterpriseSsoAction": "Try Enterprise SSO", + "enterpriseSsoSubmitting": "Trying enterprise SSO...", "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 53eae3211..cbca5da55 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -115,6 +115,12 @@ "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", + "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到配置的直连认证提供方,而不是固定使用本地账号接口。", + "enterpriseSsoTitle": "企业单点登录", + "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在企业 SSO 会话,可直接尝试建立 SkillHub 登录态。", + "enterpriseSsoAutoHint": "当前部署已启用自动企业单点登录探测。若未成功,你仍可继续使用现有登录方式。", + "enterpriseSsoAction": "尝试企业 SSO 登录", + "enterpriseSsoSubmitting": "正在尝试企业 SSO 登录...", "agreementPrefix": "登录即表示你同意我们的", "terms": "服务条款", "and": "和", diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 44be95902..e07b994ce 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,8 +1,10 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { getDirectAuthRuntimeConfig } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' -import { useLocalLogin } from '@/features/auth/use-local-auth' +import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry' +import { usePasswordLogin } from '@/features/auth/use-password-login' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' @@ -11,7 +13,8 @@ export function LoginPage() { const { t } = useTranslation() const navigate = useNavigate() const search = useSearch({ from: '/login' }) - const loginMutation = useLocalLogin() + const loginMutation = usePasswordLogin() + const directAuthConfig = getDirectAuthRuntimeConfig() const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -41,62 +44,73 @@ export function LoginPage() {
- - - {t('login.tabPassword')} - {t('login.tabOAuth')} - +
+ navigate({ to: returnTo })} + /> - -
-
- - setUsername(event.target.value)} - placeholder={t('login.usernamePlaceholder')} - /> -
-
- - setPassword(event.target.value)} - placeholder={t('login.passwordPlaceholder')} - /> -
- {loginMutation.error ? ( -

{loginMutation.error.message}

- ) : null} - -

- {t('login.noAccount')} - {' '} - - {t('login.register')} - -

-
-
+ + + {t('login.tabPassword')} + {t('login.tabOAuth')} + + + +
+ {directAuthConfig.enabled ? ( +

+ {t('login.passwordCompatHint')} +

+ ) : null} +
+ + setUsername(event.target.value)} + placeholder={t('login.usernamePlaceholder')} + /> +
+
+ + setPassword(event.target.value)} + placeholder={t('login.passwordPlaceholder')} + /> +
+ {loginMutation.error ? ( +

{loginMutation.error.message}

+ ) : null} + +

+ {t('login.noAccount')} + {' '} + + {t('login.register')} + +

+
+
- -

- {t('login.oauthHint')} -

- -
-
+ +

+ {t('login.oauthHint')} +

+ +
+ +