feat(sso): WIS-631 SSO v2 — three-secret state machine (F1/F2/bsid) + C2/N5 callback + dual gate - #37
Merged
Merged
Conversation
/api/sso/login panicked at controller/sso.go (sessions.Default) because the
gin-contrib/sessions middleware was never registered anywhere in the app. Even
if it had been, it could not authenticate: middleware.UserAuth reads the
Authorization Bearer header (new-api dashboard JWT or PAT), not a
gin-contrib/sessions cookie — so the wallet/dashboard routes would still 401
and SSO could never actually log the browser in.
Reimplement SsoLogin against the existing login model: validate the PAT via
model.ValidateAccessToken (the token wischoicer-user mints through
POST /api/user/{id}/generate_access_token), then
service.CreateLoginSession + service.WriteRefreshCookie to issue a real
user_sessions row plus the httpOnly refresh cookie, then 302 to a same-origin
path. The SPA already bootstraps the Authorization access_token from that
refresh cookie on load (web/src/lib/auth-session.ts:bootstrapAuthentication ->
POST /api/user/auth/refresh), so UserAuth-protected routes authenticate.
Also harden the redirect sanitizer to reject the backslash authority form
(/\host, which several browsers normalize to //host), closing an open-redirect
gap in the 302 Location header.
Drops the now-unused gin-contrib/sessions import.
Tests in controller/sso_test.go cover: valid PAT mints a session + sets the
refresh cookie + 302 (the former panic path); invalid PAT returns 401 with no
session/cookie; redirect sanitizer neutralizes //host, https://host, and /\host.
Refs WIS-621
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address 记星 R3 blockers ① and ③. These are independent of the R3 credential decision (②), which waits on the Owner. ① open-redirect (P1): the prior sanitizer only inspected byte[1], so the form value "%2F%09%2Fhost" decoded to "/<TAB>/host" and passed — browsers strip TAB during URL parsing and treat it as the authority "//host". Replace it with safeSameOriginRedirect: reject ASCII control bytes (<0x20, 0x7f) and backslashes anywhere, require a single leading '/' and no scheme/host, then normalize with path.Clean (query/fragment preserved). Regression now covers HTAB/CR/LF, repeated leading slashes, backslash, null/interior control bytes, absolute and scheme-relative URLs, and the percent-encoded bypass through real form parsing. ③ Add Cache-Control: no-store to the SSO 302 (matching password login), and add an end-to-end test proving the refresh cookie issued by SsoLogin feeds POST /api/user/auth/refresh to mint an access_token that then authenticates a real middleware.UserAuth-protected route back to the SSO user — the closed loop the SPA bootstrap chain relies on. Tests: controller/sso_test.go — 5 top-level tests, 17 redirect sub-cases, all green; ./controller/ and ./service/ packages green. Refs WIS-621 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SSO v2 foundation (RFC v4 §2 N2): inbound service-token middleware for the new
internal call points, mirroring wischoicer_billing_auth.go (Token B) — SHA-256
fixed-width + dual-slot constant-time compare, no short-circuit, 401
UNAUTHORIZED with {success,code,message} envelope, no token/slot leak.
- common/wischoicer_recharge_config.go: WischoicerSsoServiceToken/Next +
WischoicerFeeReadToken/Next + WischoicerSsoInternalEnabled /
WischoicerFeeReadEnabled, env WISCHOICER_SSO_SERVICE_TOKEN(_NEXT) +
WISCHOICER_FEE_READ_TOKEN(_NEXT); current empty → route not mounted
(fail-closed). Independent from Token A/B, never reused.
- middleware/wischoicer_sso_auth.go: WischoicerSsoInternalAuth (C2
POST /api/sso/wischoicer/authorize), header X-Wischoicer-Sso-Service-Token.
- middleware/wischoicer_fee_read_auth.go: WischoicerFeeReadAuth (C5 fee-read),
header X-Wischoicer-Fee-Read-Token. (C5 落点 = D3 待记星 round-4.)
- middleware/wischoicer_sso_auth_test.go: failure-envelope (401 + code) +
current/next dual-slot pass, for both middleware.
Middleware + token holders only — no routes mounted yet (N1/N4 wire them).
Gates (WischoicerSSOEnabled/LegacySsoPatDisabled) + WISCHOICER_SSO_PUBLIC_ORIGIN
/ WISCHOICER_SSO_AUTHORIZE_URL boot validation (N3) land with N1 (start).
go test ./middleware/ ./common/ green; gofmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…boot validation
SSO v2 config foundation for /start (RFC v4 §2 N1 + §1.4 N3). New
common/wischoicer_sso_config.go:
- WischoicerSsoEnabled + LegacySsoPatDisabled: two INDEPENDENT gates (互不推导,
RFC v4 §2 N5) — allow pre/dual/cutover/fallback phasing.
- WischoicerSsoEnabled=true requires WISCHOICER_SSO_PUBLIC_ORIGIN pure-origin
(https+host, path ∈ {"","/"}, no query/fragment/userinfo) AND
WISCHOICER_SSO_AUTHORIZE_URL (https + fixed /bff/gateway/user/sso/authorize);
enabled-but-invalid → boot reject (fail-closed, main FatalLog).
- WischoicerSsoCallbackURL(code): URL builder = TrimRight(origin,"/") + fixed
callback path + escape(code) — no user-input concat (RFC v4 §1.4).
- initWischoicerSsoConfig wired after initWischoicerRechargeConfig (common/init.go).
Tests (TDD green): origin/authorize-URL validation negatives (http/path/query/
fragment/userinfo/empty/wrong-path/trailing-slash); gate truth table (disabled
skips / enabled-valid passes / enabled-bad-origin rejects / enabled-bad-authorize
rejects / legacy-gate independent); callback URL builder. Full common suite green;
gofmt clean.
/start handler + route (consume WischoicerSsoEnabled/AuthorizeURL) land next —
this is the security config foundation only, no routes mounted. Does not touch
N2 middleware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wischoicerTokenMatchesAnySlot returned `matchCur == 1 || matchNext == 1`; Go's || short-circuits — objdump (记星 f726d64e) shows a conditional jump on matchCur, so the result phase still carried a slot-dependent branch even though both ConstantTimeCompare ran. Change to bit-OR `(matchCur | matchNext) == 1`: both results (each 0/1) OR'd, no branch. The helper feeds Token B + SSO + fee-read, so all three middleware inherit the fix. - middleware/wischoicer_billing_auth.go: return (matchCur | matchNext) == 1. - middleware/wischoicer_sso_auth_test.go: TestWischoicerTokenMatchesAnySlot_BitOrAggregation pins the aggregation (both-wrong / current / next / both). P2 (fail-closed config tests: current空/next有 → Enabled=false + middleware- direct-401; env unset idempotent; snapshotWischoicerConfig covers new globals) batches with N4 conditional-mount tests, not this commit (per 张驰's sequencing). go test -race ./middleware ./common green; go vet ./middleware clean; gofmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/sso/wischoicer/start (RFC v4 §2 N1), mounted only when
WischoicerSsoEnabled (boot-validated). The handler:
- creates an AuthFlow (purpose=wischoicer_sso, TTL=5min) via the existing
model.CreateAuthFlow → flow_token (F1);
- sets a browser-binding state cookie (value=flow_token, HttpOnly+Secure+
SameSite=Lax+Path=/+TTL) — Secure drops on plain HTTP (SSO needs HTTPS);
- 302 to ${WISCHOICER_SSO_AUTHORIZE_URL}?flow_token=F1 via url.URL builder
(no string concat, §1.4).
Accepts no user-supplied redirect/origin/target — callback landing is fixed
(§1.4), closing open-redirect. Does not touch N2 middleware.
- model/auth_flow.go: AuthFlowPurposeWischoicerSSO = "wischoicer_sso".
- controller/wischoicer_sso.go: SsoStart.
- router/api-router.go: register GET /sso/wischoicer/start (SsoRateLimit + gate).
- controller/wischoicer_sso_test.go: 302 + flow_token non-empty + state cookie
(value=flow_token, HttpOnly/Secure/SameSite=Lax/Path=/).
go test ./controller/ ./middleware/ ./common/ green; gofmt clean; vet clean on
changed files (pre-existing lock-copy/IPv6 warnings in untouched files, noted by
记星).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ no-referrer/no-store N5 contract-independent pieces (callback 烧码建 session deferred — it consumes the one-time code F2 whose storage/linkage is C2/N4 design, gated): - controller/wischoicer_sso.go: state cookie Path narrowed from "/" to /api/sso/wischoicer (RFC §4 line 178) as shared const wischoicerSsoStateCookiePath — /start set + future callback read align on one symbol; 记星's Path review flips one line. /start 302 now sets Cache-Control: no-store + Referrer-Policy: no-referrer (don't cache the flow_token-bearing 302; don't leak flow_token via Referer to the authorize hop). - controller/wischoicer_sso_test.go: assert Path == wischoicerSsoStateCookiePath + the two security headers. Deferred (needs C2/N4 F2 contract): callback handler (verify state cookie ↔ code, atomically consume code, CreateLoginSession + refresh cookie, redirect). Lands once C2/N4 (issues the one-time code + binds the flow's user) is concrete, or next turn with the exact F2 contract extracted from RFC §4. go test -race ./controller/ ./middleware/ ./common/ green; gofmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1-1 (承重, three-secret model — 记星 ff277017): /start previously wrote F1 itself into the state cookie + left F1.payload empty → "holding F1 = holding the cookie", so callback couldn't bind by bsid. Fix the three independent secrets: - generate high-entropy bsid (32B); state cookie now stores ONLY bsid (not F1); F1.payload = HMAC(SessionSecret-derived key, bsid) (context binding, not empty). - C2 (N4) will carry the bsid_hash into F2; callback (N5) verifies cookie(bsid) ↔ F2.payload in the F2-burn tx. A leaked/exfiltrated F1 alone cannot complete SSO. - TestSsoStart now asserts cookie.Value ≠ F1 (== bsid) + F1.payload == HMAC(bsid). P1-2 (boot fail-closed): - wischoicerBoolEnv tightened: only empty/unset/"false" → false, "true" → true; any OTHER non-empty value → error reporting the var name (防 gate 拼写错误静默失效). - WischoicerSsoEnabled=true && !SessionCookieSecure → reject boot (callback refresh cookie must be Secure; SessionCookieSecure set earlier in InitEnv). - gate-truth-table test adds !Secure-reject + bad-bool-reject cases. go test -race ./controller/ ./middleware/ ./common/ green; vet clean on changed files (pre-existing lock-copy/IPv6 warnings in untouched files); gofmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
记星 edec1c4b F2 contract nailed. The coupled second half of the three-secret
flow (F1 from /start, F2 from C2, bsid from cookie):
C2 — POST /api/sso/wischoicer/authorize (WischoicerSsoInternalAuth + gate):
user-service broker calls with {flow_token=F1, new_api_user_id}. In the SAME tx
as consuming F1, mints F2 (one-time login code) with F1.payload copied VERBATIM
into F2.payload (no decode/recompute); returns callback_url = WischoicerSsoCallbackURL(F2).
N5 callback — GET /api/sso/wischoicer/callback?code=F2 (public, gate):
- in the consume-F2 tx: constant-time verify cookie(bsid) → HMAC ↔ F2.payload;
the action returns nil REGARDLESS (失败也提交: cookie missing/format-error/hash-
mismatch all commit the F2 burn first, then unified failure — each wrong probe
burns a code, no bsid brute-force). CreateLoginSession is OUTSIDE the consume tx
(avoids nested write-tx deadlock on sqlite); on system error F2 is already burned.
- bsid match → CreateLoginSession + refresh cookie → /dashboard; else → /login (unified).
- one-shot: clears the state cookie (symmetric Path/Secure/HttpOnly/SameSite with /start).
- F2 not replayable (one-time consume).
model: CreateAuthFlowWithTx(tx, input) — tx-scoped variant for C2's same-tx F2 mint.
router: register C2 (internal-auth) + callback (public), both WischoicerSsoEnabled-gated.
Tests (file-sqlite per-test to avoid :memory: per-connection empty-DB): C2 mints F2
with F1.payload verbatim; callback happy-path session; wrong-bsid burns F2 (unified
fail, no session); cookie-missing burns F2 (unified fail); F2 not replayable.
go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 (承重, 记星 9c4b5fee): F1/F2 both used Purpose=wischoicer_sso → C2/callback consumers ate each other's token (callback could consume F1, C2 could consume F2 + re-mint). Fix: split into AuthFlowPurposeWischoicerSSOStart (F1, /start creates, C2 consumes) + AuthFlowPurposeWischoicerSSOCode (F2, C2 mints, callback consumes). C2 matches only Start; callback matches only Code. Regression: callback(F1) rejects + does NOT consume; C2(F2) rejects + does NOT consume. P2-1: callback 302 now has Referrer-Policy: no-referrer (was only no-store). P2-3 (Brooks-Lint): gofmt wischoicer_sso.go; cleaned dead placeholder + replaced hand-written itoa with strconv.Itoa; CreateAuthFlowWithTx nil-tx guard. NOT touched: CreateLoginSession-outside-tx design (passed 记星 edec1c4b); 77cc8b0-. go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean. 7 callback tests (C2 mint + happy + wrong-bsid-burns + cookie-missing-burns + not-replayable + callback-rejects-F1 + C2-rejects-F2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
记星 9c4b5fee P2-2 三条 committed 回归(纯测试,不改实现): 1. ConcurrentDoubleCallback_ExactlyOneSession: 一 F2 并发双 callback → 恰一 /dashboard + 一 /login + 库内恰一 session(DB CAS consumed_at IS NULL 原子消费; file-sqlite busy_timeout=5000 让第二写事务等待而非 locked-error;-race)。 2. CreateLoginSessionFails_F2ConsumedNoCookie: F2.UserId=999999(不存在)→ callback 消费 F2(burn)→ CreateLoginSession 失败 → /login。F2 ConsumedAt≠nil(仍烧), 无 refresh cookie。 3. ClearCookieSymmetric: callback 清 state cookie 的 Path/Secure/HttpOnly/SameSite/ MaxAge 逐项与 /start set 对称(wischoicerSsoStateCookiePath 共用常量)。 Setup: PRAGMA busy_timeout=5000 for concurrent test support. sync import added. go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean. 10 callback tests total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eplay 记星 de34eafd P2×3 闭合(纯测试,零实现改动): P2-1(最要紧): 并发用例加 ready/start barrier(两 goroutine parked 才 close 放行); 新增 model 层证明 TestConsumeAuthFlowWithAction_ConcurrentF2_ExactlyOneConsumes——直接 断言两次 ConsumeAuthFlowWithAction 恰一次 nil + 一次 ErrAuthFlowConsumed(不靠 HTTP Location 反推,区分得出 lock 错 vs 真 consumed)。HTTP 层保留恰一 session 断言(带 barrier)。 P2-2: callback happy + wrong-bsid 两条都断言 Referrer-Policy: no-referrer。 P2-3: C2 重放同一 F1 → 首次 200、二次 400、恰一 F2 count=1、F1 consumed。 go test -race ./controller/ ./middleware/ ./common/ green; vet/gofmt clean. 11 callback tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ontract 张驰 B 决策:SQLite busy 是测试 artifact(生产行锁下 loser = consumed)。安全契约是 「至多一次副作用 + loser fail-closed」,不是「loser 必为 consumed」。不动 model 承重。 SQLite 测试 (controller/wischoicer_sso_callback_test.go): - barrier 改对:allReady WaitGroup(2) + start channel;goroutine 先 Done() 再 <-start, 主线程 Wait() 后 close(start)。上轮 close(ready) 不是真 barrier。 - 断言诚实:一次成功 + 一次非成功(busy/consumed 都接受)+ 仅一个 side effect。 删掉 ErrAuthFlowConsumed 强断言。-count=100 稳定绿。 MySQL/PostgreSQL exact contract (model/wischoicer_sso_concurrent_integration_test.go, build tag integration):barrier 并发 → 断言恰一次 nil + 一次 ErrAuthFlowConsumed。 CI database:[mysql,postgres] 跑。 Recon: 生产 DB = MySQL/PostgreSQL(非 SQLite)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
关联
d8cdc28b+ recon 路由矩阵0853ed81;D1(公网 edge 保留 UserAuth 仅拒 C2/C5、legacy 分阶段)+ D2(SSO broker 只读 fail-closed、零 provisioning)锁定背景
Bug 2 根因: new-api
controller/sso.go的 legacyPOST /api/sso/login用sessions.Default(c)但 upstream QuantumNous#6329 删了 gin sessions 注册 → panic → 白屏。SSO v2 用三秘密状态机(F1/F2/bsid)+ stateless token 替换 gin sessions,彻底关 login-CSRF。改动摘要
N1 —
/start入口 + 双 gate + N3 boot 校验GET /api/sso/wischoicer/start:WischoicerSsoEnabledgate +SsoRateLimit先于 handler(429 不建 flow)。生成独立高熵 bsid(32B),种 state cookie(值=bsid、HttpOnly+Secure+SameSite=Lax+Path=/api/sso/wischoicer),建 F1 AuthFlow(Purpose=wischoicer_sso_start、Payload=HMAC(bsid)),302 到${WISCHOICER_SSO_AUTHORIZE_URL}?flow_token=F1(URL builder + no-store/no-referrer)。WischoicerSsoEnabled(envWISCHOICER_SSO_ENABLED)+LegacySsoPatDisabled(envWISCHOICER_SSO_LEGACY_PAT_DISABLED),支持 pre/dual-track/cutover/fallback 分阶段。WischoicerSsoEnabled=true强制WISCHOICER_SSO_PUBLIC_ORIGIN纯 origin(https+host、path∈{"","/"}、无 query/fragment/userinfo)+WISCHOICER_SSO_AUTHORIZE_URL(https + 固定 path/bff/gateway/user/sso/authorize)+SessionCookieSecure=true;不满足 → boot 拒启动(fail-closed)。wischoicerBoolEnv仅接受空/true/false,其它非空值 → 报变量名 + 拒启动。N2 — C2/C5 入站鉴权 middleware + 双槽 token holder
wischoicer_sso_auth.go:WischoicerSsoInternalAuth(C2POST /api/sso/wischoicer/authorize入站 sso-service-token)。wischoicer_fee_read_auth.go:WischoicerFeeReadAuth(C5 fee-read-token)。wischoicer_billing_auth.go(Token B): SHA-256 定宽 + current/next 双槽位聚合(matchCur|matchNext)==1(不短路,记星 R2 P1 闭合)。WischoicerSsoServiceToken/Next+WischoicerFeeReadToken/Next(envWISCHOICER_SSO_SERVICE_TOKEN(_NEXT)/WISCHOICER_FEE_READ_TOKEN(_NEXT),current 空 → 路由不挂载 fail-closed)。CreateAuthFlowWithTxnil-tx guard。C2 —
POST /api/sso/wischoicer/authorize(mint F2)WischoicerSsoInternalAuth+WischoicerSsoEnabledgate。收{flow_token=F1, new_api_user_id}。在消费 F1 的同一事务里 mint F2(CreateAuthFlowWithTx,Purpose=wischoicer_sso_code、UserId=new_api_user_id、F1.payload 原样复制进 F2.payload 不解码/不重算,记星edec1c4b),返回callback_url。F1 一次性消费不可重放。N5 —
GET /api/sso/wischoicer/callback(consume F2 + bsid verify + session)subtle.ConstantTimeCompare)。action 始终返回 nil → 失败也提交烧码(cookie 缺失/格式错/hash 不符都先提交 F2 消费 + 统一失败/login,记星edec1c4b「失败也提交」防 bsid 爆破)。CreateLoginSession在事务外(避免嵌套写 tx 死锁)。匹配 → session + refresh cookie +/dashboard;否则 →/login。一次性 clear state cookie(Path/Secure/HttpOnly/SameSite 与 /start set 对称)。F1/F2 阶段类型隔离(记星
9c4b5feeP1)wischoicer_sso_start(C2 精确匹配消费);F2=wischoicer_sso_code(callback 精确匹配消费)。两 consumer 互拒对方 token + 不消费。并发语义(张驰 B 决策)
SQLITE_BUSY而非ErrAuthFlowConsumed——这是测试 artifact,不是安全差。ErrAuthFlowConsumed(阻塞等 winner 提交 → 看到 consumed)。ErrAuthFlowConsumed契约挂model/wischoicer_sso_concurrent_integration_test.go(build tagintegration,CIdatabase:[mysql,postgres]跑)。allReadyWaitGroup +startchannel。-count=100 -race稳定绿。环境变量
WISCHOICER_SSO_ENABLEDWISCHOICER_SSO_LEGACY_PAT_DISABLED/api/sso/login返 410(cutover 阶段)。与 Enabled 互不推导WISCHOICER_SSO_PUBLIC_ORIGINWISCHOICER_SSO_AUTHORIZE_URL/bff/gateway/user/sso/authorize)WISCHOICER_SSO_SERVICE_TOKEN(_NEXT)WISCHOICER_FEE_READ_TOKEN(_NEXT)CI 证据
go test -race -count=100 ./controller/ -run Concurrent→ 稳定绿30202196022六项全绿: mysql job89793948883(220s) + postgresql job89793948912(55s) → exactErrAuthFlowConsumed契约真跑go vet/gofmt/diff-check干净(pre-existing custom-event/email_test 告警非本次)兼容性 / Breaking Change
/api/sso/wischoicer/{start,callback,authorize}),仅在WischoicerSsoEnabled=true时挂载。默认 false(现有部署不受影响)。POST /api/sso/login行为不变(LegacySsoPatDisabled=false透传)。cutover 阶段=true返 410。AuthGatewayFilter/ BFFinjectAuthHeaders/ 前端订单 body。风险
SESSION_SECRET无双槽轮换——SSO v2 的 HMAC(bsid) + AuthFlow token hash 都派生自它。轮换SESSION_SECRET会使所有在途 F1/F2 失效(用户需重新 /start)。回滚
WISCHOICER_SSO_ENABLED=false(SSO v2 路由全部 unmount)。WischoicerSsoEnabled必须所有节点一致——半开半关会导致/start成功但/callback404(部分节点未挂载 callback 路由)。LegacySsoPatDisabled可独立保持false(恢复 legacy 透传)或true(继续 410)。Gate
R3(鉴权 / 安全边界)。技术 R2 已闭(记星
fd2b2d91通过)。请 @jirui Zhao 拍 R3 合并。🤖 Generated with Claude Code