Skip to content

Commit fde9cd4

Browse files
longsizhuoclaude
andauthored
feat(sso): IH 通行证——授权码 + PKCE,让 HoloCard 用 IH 账号登录(INV-010) (#59)
* feat(sso): IH 通行证服务端——授权码 + PKCE,让 HoloCard 用 IH 账号登录 按三方协议实现后端的两步(新增 INV-010): - POST /oauth/sso/code(需要登录):IH 前端 /sso/authorize 页代当前用户签码。 client 必须在 sso.clients 表里且已启用,redirectUri 与登记值逐字相等,只收 S256 和 43 位 base64url 的 challenge,state 1–512;不过就 400,账号被禁用 403。 码是 32 字节随机数的 base64url,存进程内 Caffeine,60 秒过期;返回 {redirect: redirectUri?code=…&state=<URL 编码>}。 - POST /internal/sso/token(加进 notMatch,controller 自己鉴权):client secret 常量 时间比较,不过就 401 invalid_client,并且不碰码(拿不到 secret 的人烧不掉别人的码); asMap().remove 取码即作废;clientId / redirectUri / verifier 任一不符、或账号已被 禁用,都是 400 invalid_grant,码不可重试。只返回 sub/username/displayName/avatarUrl, 不给 email、角色权限,也不给任何 IH token。 client 按表配置(sso.clients.<id>.secret / .redirect-uri),代码里不认具体 client 名。 secret 或 redirect-uri 为空即未启用、两个接口都拒绝(空 redirect-uri 也拒:否则请求传 空串就能"逐字相等");启动日志逐个播报 client 是否启用,不打 secret。docker-compose 和 .env.example 补 SSO_HOLOCARD_SECRET。 新代码都在 sso 包,没碰 OAuthController / AuthService(#57 在改)。 测试:SsoControllerIntegrationTests 走真登录 → 签码 → 换码,覆盖成功路径和各条拒绝 路径;SsoServiceTests 用假时钟测 60 秒过期。对签码/换码的 10 处核心校验各做一次变异, 测试全部能抓到。全量 346 个测试通过。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF * feat(sso): 给 HoloCard 的 staging 单独登记一个 client(holocard-staging) staging 跑着没合并的 PR,secret 和回跳地址都和线上分开:它拿到的码换不了线上的会话,反过来也一样。 SSO_HOLOCARD_STAGING_SECRET 留空就不启用;加一条测试确认带横线的 client id 能从 application.properties 原样读进 client 表。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0e711d0 commit fde9cd4

13 files changed

Lines changed: 890 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ AUTH_DISCORD_ALLOWLIST=
7272
# 本地开发:为 true 时注册验证码打印到控制台而不真发信(免配 Resend)。生产保持 false。
7373
REGISTRATION_OTP_DEV_CONSOLE=false
7474

75+
# IH 通行证(HoloCard 用 IH 账号登录,INV-010)。HoloCard 换码时带的 client secret,
76+
# 两边必须配同一个值(可用 openssl rand -hex 32 生成)。留空 = 不启用,本地开发一般留空。
77+
# 回跳地址默认 https://holocard.longsizhuo.com/auth/callback,要改就设 SSO_HOLOCARD_REDIRECT_URI。
78+
SSO_HOLOCARD_SECRET=
79+
# HoloCard staging 单独一个 client(回跳 https://holocard.staging.longsizhuo.com/auth/callback),同样留空 = 不启用
80+
SSO_HOLOCARD_STAGING_SECRET=
81+
7582
# --- AI 模型(用 OpenAI 兼容协议调用 GLM-4.6V-Flash 作为默认 fallback) ---
7683
#
7784
# 变量名沿用 OPENAI_* 是因为 Java 代码里用的是 OpenAI /chat/completions 标准协议,

‎SECURITY.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,52 @@
182182
INV-005a 只管 5432,这条覆盖所有端口,以后新加服务也一样。
183183
- **历史**:2026-10-07 在服务器上抓包,3 分钟内有 4 个外网扫描器连到 8080,从公网 IP 访问
184184
`/actuator/metrics` 返回 200;随本不变量改成 `127.0.0.1:8080:8080`。
185+
186+
## INV-010 · IH 通行证:只给登记过的 client 签码,换码必须 client secret + PKCE
187+
188+
- **保护点**:
189+
- `sso/service/SsoService#issueCode`(`POST /oauth/sso/code`,需要登录):client 必须在 `sso.clients`
190+
表里且已启用;`redirectUri` 与登记值逐字相等(不做前缀、通配,不归一化大小写、尾斜杠、query);
191+
只收 `codeChallengeMethod=S256` 和 43 位 base64url 的 `codeChallenge`;disabled 账号 403。
192+
码存在进程内 Caffeine,60 秒过期。
193+
- `sso/service/SsoService#authenticateClient`(`POST /internal/sso/token`):client 已启用,且
194+
`clientSecret` 用 `MessageDigest.isEqual` 常量时间比较;不过就 401 `invalid_client`,**并且不碰码**。
195+
- `sso/service/SsoService#redeem`:`asMap().remove` 取码即作废(只能用一次);clientId、redirectUri、
196+
`codeVerifier`(格式 + S256 常量时间比较)任一不符,或账号此时已不存在 / 被禁用,都是 400
197+
`invalid_grant`,码不可重试;成功只返回 `sub/username/displayName/avatarUrl`(`sso/dto/SsoUserInfo`)。
198+
- `sso/config/SsoProperties.Client#enabled`:secret 或 redirect-uri 为空即未启用,两个接口都拒绝
199+
(fail closed);`SsoService` 构造时在启动日志里逐个播报 client 启用与否,不打 secret。
200+
- `common/config/SaTokenConfigure`:只放行 `/internal/sso/token` 这一条精确路径,`/oauth/sso/code` 不放行。
201+
- **测试**:
202+
- `SsoControllerIntegrationTests#seedAccountCanSignInAndGetsOnlyTheMinimalProfile`(端到端 + 返回字段白名单)
203+
- `SsoControllerIntegrationTests#profileNeverCarriesEmailRolesPermissionsOrToken`
204+
- `SsoControllerIntegrationTests#issuingCodeRequiresLogin`
205+
- `SsoControllerIntegrationTests#redirectUriMustMatchTheRegisteredValueExactly`
206+
- `SsoControllerIntegrationTests#unknownClientCannotGetCode`
207+
- `SsoControllerIntegrationTests#malformedPkceOrStateIsRejected`
208+
- `SsoControllerIntegrationTests#stateLengthBoundsAreInclusive`(反向)
209+
- `SsoControllerIntegrationTests#disabledAccountCannotGetCode`
210+
- `SsoControllerIntegrationTests#clientWithEmptySecretIsRejectedByBothEndpoints`
211+
- `SsoControllerIntegrationTests#wrongSecretIsInvalidClientAndDoesNotBurnTheCode`
212+
- `SsoControllerIntegrationTests#codeCanOnlyBeRedeemedOnce`
213+
- `SsoControllerIntegrationTests#wrongVerifierBurnsTheCode`
214+
- `SsoControllerIntegrationTests#verifierFormatIsEnforcedEvenWhenItsHashMatches`
215+
- `SsoControllerIntegrationTests#redirectUriAtRedemptionMustMatchTheCode`
216+
- `SsoControllerIntegrationTests#anotherClientCannotRedeemTheCode`
217+
- `SsoControllerIntegrationTests#accountDisabledAfterCodeWasIssuedCannotRedeem`
218+
- `SsoServiceTests#codeExpiresAfter60Seconds`(假时钟)
219+
- `SsoServiceTests#clientNeedsBothSecretAndRedirectUriToBeEnabled`
220+
- `SsoControllerIntegrationTests#stagingClientFromApplicationPropertiesBindsWithItsDashedId`
221+
- **为什么**:签码等于 IH 替用户向别的站点担保"这是谁",任何一环松了都是账号接管:
222+
1. redirect_uri 一旦放宽成前缀或通配,攻击者构造 `/sso/authorize?redirect_uri=<自己的地址>`,
223+
受害者点"继续",码就直接送到攻击者手里——OAuth 最常见的被打穿方式。
224+
2. 码会经过地址栏、浏览历史、Referer 和各种日志。PKCE 让偷到码的人换不出东西(verifier 只在
225+
HoloCard 的 HttpOnly cookie 里);60 秒过期 + 只能用一次压缩重放窗口;核对不上就作废,不给反复试。
226+
3. `/internal/sso/token` 必须进 notMatch(调用方没有 IH 登录态),而 Caddy 把所有路径原样转给 8080,
227+
这条路径公网可达,client secret 是唯一的门:比较要常量时间;secret 为空必须拒绝——空串对空串
228+
`MessageDigest.isEqual` 判相等,漏配 env 就等于换码接口不设防。启动播报的理由同 INV-008:
229+
漏配和故意停用行为上一样,只能靠日志区分。没过 client 鉴权的请求不能碰码,否则谁都能烧掉别人的码。
230+
4. 交出去的资料只够对方建自己的会话。email、角色、权限不给(对方用不上,泄露面越小越好);
231+
IH 的 satoken 绝不出 IH——给出去,HoloCard 一旦被攻破就能以用户身份操作 IH。
232+
- **历史**:2026-10-07 随 HoloCard 接入 IH 通行证引入(授权码 + PKCE S256)。client 表:`holocard`(线上)、
233+
`holocard-staging`(HoloCard 的 staging 跑着没合并的 PR,单独的 secret 和回跳地址,拿到的码换不了线上的会话)。

‎docker-compose.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ services:
3838
# GitHub REST API token:DocHistoryService 调 /repos/.../commits 需要,
3939
# 匿名限流 60/hour,带 token 5000/hour。未设置时 service 仍可用,只是容易被限流。
4040
GITHUB_TOKEN: ${GITHUB_TOKEN:-}
41+
# IH 通行证(INV-010):HoloCard 换码时带的 client secret,和 HoloCard 那边配同一个值。
42+
# 留空 = holocard 这个 client 未启用,签码/换码接口都拒绝,启动日志会打 [SSO] 警告。
43+
SSO_HOLOCARD_SECRET: ${SSO_HOLOCARD_SECRET:-}
44+
# HoloCard staging 的 client(和线上分开的 secret、回跳地址),留空 = 未启用
45+
SSO_HOLOCARD_STAGING_SECRET: ${SSO_HOLOCARD_STAGING_SECRET:-}
4146
# 只 bind 到 loopback(INV-009)。公网流量一律走 Cloudflare → Caddy(api.involutionhell.com),
4247
# Caddy(host 网络)和 ChatBot 都直连 127.0.0.1:8080,不受影响。
4348
# Docker 发布端口走 nat 表 DNAT,不经过主机 INPUT 链,iptables 挡不住;

‎src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ public void addInterceptors(InterceptorRegistry registry) {
5252
// 不要求 sa-token 登录态。Controller 自己校验密钥。
5353
// 用 /** 覆盖子路径(/internal 提交 + /internal/summary 查询)。
5454
.notMatch("/api/community/links/internal", "/api/community/links/internal/**")
55+
// IH 通行证换码(INV-010):调用方是 HoloCard 等 client 的服务端,没有 IH 登录态。
56+
// SsoController 自己用 client secret 鉴权(常量时间比较,secret 未配置即拒绝)。
57+
// 只放行这一条精确路径;签码的 /oauth/sso/code 需要登录,不能加进来。
58+
.notMatch("/internal/sso/token")
5559
.notMatch("/api/chat/sessions/save") // AI 对话持久化(匿名 / 登录都写,登录时自动关联 userId)
5660
// Posts 公开读接口:
5761
// GET /api/posts/feed - /feed 原创 Tab 列表(匿名可访问)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.involutionhell.backend.sso.config;
2+
3+
import java.util.Map;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
6+
/**
7+
* IH 通行证的 client 表(INV-010):{@code sso.clients.<clientId>.secret / .redirect-uri}。
8+
* 接新站点只加配置,代码里不认任何具体的 client 名。
9+
*/
10+
@ConfigurationProperties(prefix = "sso")
11+
public record SsoProperties(Map<String, Client> clients) {
12+
13+
public SsoProperties {
14+
clients = clients == null ? Map.of() : Map.copyOf(clients);
15+
}
16+
17+
/**
18+
* @param secret 换码接口认 client 用的共享密钥
19+
* @param redirectUri 唯一允许的回跳地址,签码时逐字比较
20+
*/
21+
public record Client(String secret, String redirectUri) {
22+
23+
/**
24+
* secret 和 redirect-uri 都配了才算启用,否则两个接口都拒绝(fail closed)。
25+
* redirect-uri 为空也算未启用:不然请求里传空串就能和登记值"逐字相等"。
26+
*/
27+
public boolean enabled() {
28+
return secret != null && !secret.isBlank() && redirectUri != null && !redirectUri.isBlank();
29+
}
30+
}
31+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.involutionhell.backend.sso.controller;
2+
3+
import cn.dev33.satoken.annotation.SaCheckLogin;
4+
import cn.dev33.satoken.stp.StpUtil;
5+
import com.involutionhell.backend.common.api.ApiResponse;
6+
import com.involutionhell.backend.sso.dto.SsoCodeRequest;
7+
import com.involutionhell.backend.sso.dto.SsoTokenRequest;
8+
import com.involutionhell.backend.sso.dto.SsoUserInfo;
9+
import com.involutionhell.backend.sso.service.SsoService;
10+
import java.util.Map;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
/**
18+
* IH 通行证(INV-010)的两个接口:
19+
* <ol>
20+
* <li>IH 前端 /sso/authorize 页代已登录用户调 {@code /oauth/sso/code} 签码,浏览器带着码跳回 client;</li>
21+
* <li>client 的服务端拿码调 {@code /internal/sso/token} 换用户资料,之后发它自己的会话。</li>
22+
* </ol>
23+
* IH 的 satoken 始终不出 IH。
24+
*/
25+
@RestController
26+
public class SsoController {
27+
28+
private final SsoService ssoService;
29+
30+
public SsoController(SsoService ssoService) {
31+
this.ssoService = ssoService;
32+
}
33+
34+
/**
35+
* 签码,需要登录。SaTokenConfigure 的全局拦截已经要求登录,这里再挂一道注解,
36+
* 防止以后有人把 /oauth/** 整个放行。
37+
*/
38+
@SaCheckLogin
39+
@PostMapping("/oauth/sso/code")
40+
public ApiResponse<Map<String, String>> issueCode(@RequestBody SsoCodeRequest request) {
41+
String redirect = ssoService.issueCode(StpUtil.getLoginIdAsLong(), request);
42+
return ApiResponse.ok(Map.of("redirect", redirect));
43+
}
44+
45+
/**
46+
* 换码。调用方是 client 的服务端,没有 IH 登录态,所以在 SaTokenConfigure 里放行,
47+
* 由这里用 client secret 鉴权。Caddy 会把这条路径转给后端,公网也能打到,secret 是唯一的门。
48+
*/
49+
@PostMapping("/internal/sso/token")
50+
public ResponseEntity<ApiResponse<SsoUserInfo>> token(@RequestBody SsoTokenRequest request) {
51+
if (!ssoService.authenticateClient(request.clientId(), request.clientSecret())) {
52+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
53+
.body(new ApiResponse<>(false, "invalid_client", null));
54+
}
55+
return ssoService.redeem(request)
56+
.map(user -> ResponseEntity.ok(ApiResponse.ok(user)))
57+
.orElseGet(() -> ResponseEntity.badRequest()
58+
.body(new ApiResponse<>(false, "invalid_grant", null)));
59+
}
60+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.involutionhell.backend.sso.dto;
2+
3+
/**
4+
* POST /oauth/sso/code 的请求体,由 IH 前端 /sso/authorize 页代当前登录用户发起。
5+
* 校验规则见 SsoService#issueCode。
6+
*/
7+
public record SsoCodeRequest(
8+
String clientId,
9+
String redirectUri,
10+
String state,
11+
String codeChallenge,
12+
String codeChallengeMethod
13+
) {
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.involutionhell.backend.sso.dto;
2+
3+
/**
4+
* POST /internal/sso/token 的请求体,由 client 的服务端(HoloCard)发起,不经过浏览器。
5+
*/
6+
public record SsoTokenRequest(
7+
String clientId,
8+
String clientSecret,
9+
String code,
10+
String codeVerifier,
11+
String redirectUri
12+
) {
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.involutionhell.backend.sso.dto;
2+
3+
/**
4+
* 换码成功后交给 client 的用户资料。故意只有这四项:不给 email、角色、权限,
5+
* 也不给任何 IH 的 token(INV-010)。加字段前先改 SECURITY.md。
6+
*
7+
* @param sub user_accounts.id 的十进制字符串
8+
*/
9+
public record SsoUserInfo(String sub, String username, String displayName, String avatarUrl) {
10+
}

0 commit comments

Comments
 (0)