Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ func InitEnv() {
if err := initWischoicerRechargeConfig(); err != nil {
log.Fatal("wischoicer recharge config invalid: " + err.Error())
}
if err := initWischoicerSsoConfig(); err != nil {
log.Fatal("wischoicer sso config invalid: " + err.Error())
}
}

func initUserSessionSettings() {
Expand Down
33 changes: 33 additions & 0 deletions common/wischoicer_recharge_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ var (
// WischoicerBillingInternalEnabled 标记 Token B 账务路由是否挂载(Token B 非空时 true)。
WischoicerBillingInternalEnabled = false

// WischoicerSsoServiceToken 是 user-service → new-api SSO authorize(C2,
// POST /api/sso/wischoicer/authorize)的入站服务凭据 sso-service-token(RFC v4 N2)。
// 与 Token A/B 完全独立、绝不复用。current/next 双槽恒时校验(镜像 Token B 接收端);
// current 为空时 C2 路由不挂载(fail-closed)。
WischoicerSsoServiceToken = ""
// WischoicerSsoServiceTokenNext 是 sso-service-token 的 next 槽(24h 无损轮换,WIS-547 R3 同构)。
WischoicerSsoServiceTokenNext = ""
// WischoicerSsoInternalEnabled 标记 C2 SSO authorize 路由是否挂载(current 非空时 true)。
WischoicerSsoInternalEnabled = false

// WischoicerFeeReadToken 是 user-service → new-api fee-read(C5,/api/internal/wischoicer/**
// 或复用 /api/log/self/feature-usage/*——D3 待记星 round-4 R3 拍板)的入站服务凭据
// fee-read-token(RFC v4 N2/U4)。独立、不复用 Token A/B/sso-service-token。
WischoicerFeeReadToken = ""
// WischoicerFeeReadTokenNext 是 fee-read-token 的 next 槽(轮换)。
WischoicerFeeReadTokenNext = ""
// WischoicerFeeReadEnabled 标记 C5 fee-read 路由是否挂载(current 非空时 true)。
WischoicerFeeReadEnabled = false

// NewApiToBillingServiceToken 持有解析后的 Token A current(new-api → billing:
// /internal/new-api/v1/recharge-orders* 内部订单接口)。方向语义明确:仅 new-api
// 持有并发送,billing 仅用于验证 internal order 路由。为空时钱包 façade 不挂载(fail-closed)。
Expand Down Expand Up @@ -86,6 +105,11 @@ const (
EnvBillingToNewApiServiceToken = "BILLING_TO_NEWAPI_SERVICE_TOKEN"
EnvBillingToNewApiServiceTokenNext = "BILLING_TO_NEWAPI_SERVICE_TOKEN_NEXT"
EnvWischoicerBillingInternalToken = "WISCHOICER_BILLING_INTERNAL_SERVICE_TOKEN"
// RFC v4 N2: user-service → new-api 入站服务凭据(C2 sso-service-token / C5 fee-read-token)。
EnvWischoicerSsoServiceToken = "WISCHOICER_SSO_SERVICE_TOKEN"
EnvWischoicerSsoServiceTokenNext = "WISCHOICER_SSO_SERVICE_TOKEN_NEXT"
EnvWischoicerFeeReadToken = "WISCHOICER_FEE_READ_TOKEN"
EnvWischoicerFeeReadTokenNext = "WISCHOICER_FEE_READ_TOKEN_NEXT"
// Token A(new-api → billing)方向语义主名 + next 槽。
EnvNewApiToBillingServiceToken = "NEWAPI_TO_BILLING_SERVICE_TOKEN"
EnvNewApiToBillingServiceTokenNext = "NEWAPI_TO_BILLING_SERVICE_TOKEN_NEXT"
Expand Down Expand Up @@ -149,6 +173,15 @@ func initWischoicerRechargeConfig() error {
// current 为空时即便 next 非空也不挂载(fail-closed,避免只配半边)。
WischoicerBillingInternalServiceTokenNext = os.Getenv(EnvBillingToNewApiServiceTokenNext)

// RFC v4 N2: user-service → new-api 入站服务凭据(C2 sso-service-token / C5 fee-read-token),
// current/next 双槽、独立不复用 Token A/B。current 为空时对应路由不挂载(fail-closed)。
WischoicerSsoServiceToken = os.Getenv(EnvWischoicerSsoServiceToken)
WischoicerSsoServiceTokenNext = os.Getenv(EnvWischoicerSsoServiceTokenNext)
WischoicerSsoInternalEnabled = WischoicerSsoServiceToken != ""
WischoicerFeeReadToken = os.Getenv(EnvWischoicerFeeReadToken)
WischoicerFeeReadTokenNext = os.Getenv(EnvWischoicerFeeReadTokenNext)
WischoicerFeeReadEnabled = WischoicerFeeReadToken != ""

// Token A(new-api → billing)+ billing 基址:解析后决定钱包 façade 是否挂载。
NewApiToBillingServiceToken = os.Getenv(EnvNewApiToBillingServiceToken)
// Token A next 槽(current/next 双槽轮换结构,WIS-547 R3 已锁)。current 为权威:
Expand Down
164 changes: 164 additions & 0 deletions common/wischoicer_sso_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package common

import (
"fmt"
"net/url"
"os"
"strings"
)

// Wischoicer SSO v2 配置(RFC v4 §1.4 N3 + §2 N1/N5)。InitEnv 一次性读取,不支持热更新。
//
// WischoicerSsoEnabled 与 LegacySsoPatDisabled 是**互不推导**的两个独立 gate(RFC v4 §2 N5):
// 允许「新流程未开但先关旧」「新旧并存」「新开旧仍开」等各阶段,部署按 pre/dual/cutover/fallback 推进。
var (
// WischoicerSsoEnabled 标记 SSO v2 入口(GET /api/sso/wischoicer/start 等)是否挂载。
// env WISCHOICER_SSO_ENABLED 显式开启,且必须同时满足 WISCHOICER_SSO_PUBLIC_ORIGIN
// 纯 origin 合法 + WISCHOICER_SSO_AUTHORIZE_URL HTTPS+固定 path 合法——任一不满足而
// env 又开了 → boot 拒绝启动(fail-closed,不静默降级到半残)。路由注册据此条件挂载。
WischoicerSsoEnabled = false

// LegacySsoPatDisabled 控制 legacy POST /api/sso/login(PAT→session)的分阶段 sunset:
// pre/dual-track 透传(false)→ cutover/fallback 才 410(true)。与 WischoicerSsoEnabled
// 互不推导——独立 true/false。RFC v4 D1:绝不一开始静态 deny,按 gate 分阶段。
LegacySsoPatDisabled = false

// WischoicerSsoPublicOrigin 是 SSO callback 的公网**纯 origin**(https+host,path 仅空或 /)。
// 用于 callback_url 拼接(URL builder 追加固定 path,禁拼请求参数,RFC v4 §1.4)。
WischoicerSsoPublicOrigin = ""

// WischoicerSsoAuthorizeURL 是 C1→C3 的固定跳转目标(HTTPS + 固定 path)。
// /start 302 到 ${WischoicerSsoAuthorizeURL}?flow_token=F1(RFC v4 §2 N1)。
WischoicerSsoAuthorizeURL = ""
)

const (
EnvWischoicerSsoEnabled = "WISCHOICER_SSO_ENABLED"
EnvWischoicerLegacySsoPatDisabled = "WISCHOICER_SSO_LEGACY_PAT_DISABLED"
EnvWischoicerSsoPublicOrigin = "WISCHOICER_SSO_PUBLIC_ORIGIN"
EnvWischoicerSsoAuthorizeURL = "WISCHOICER_SSO_AUTHORIZE_URL"

// wischoicerSsoAuthorizeFixedPath 是 C1→C3 跳转目标的固定 path(BFF cookie-only 入口,§1.2)。
wischoicerSsoAuthorizeFixedPath = "/bff/gateway/user/sso/authorize"
// wischoicerSsoCallbackFixedPath 是 C2 回调 new-api 的固定 path(§1.4)。
wischoicerSsoCallbackFixedPath = "/api/sso/wischoicer/callback"
)

// initWischoicerSsoConfig 在 InitEnv 中调用(紧跟 initWischoicerRechargeConfig)。
// WischoicerSsoEnabled=true 但 origin/authorize-url 非法 → 返回 error(main FatalLog 拒启动)。
// WischoicerSsoEnabled=false 时不校验(允许部署先配 origin 再开 gate);两个 gate 各自独立解析。
func initWischoicerSsoConfig() error {
enabled, err := wischoicerBoolEnv(EnvWischoicerSsoEnabled)
if err != nil {
return err
}
WischoicerSsoEnabled = enabled
legacy, err := wischoicerBoolEnv(EnvWischoicerLegacySsoPatDisabled)
if err != nil {
return err
}
LegacySsoPatDisabled = legacy
WischoicerSsoPublicOrigin = os.Getenv(EnvWischoicerSsoPublicOrigin)
WischoicerSsoAuthorizeURL = os.Getenv(EnvWischoicerSsoAuthorizeURL)

if !WischoicerSsoEnabled {
return nil // 未启用:不校验,允许先配后开
}
// 记星 P1-2:SSO v2 callback 的 refresh cookie 必须 Secure(HTTPS)。SessionCookieSecure 由
// InitSessionCookieSettings(init.go:65,在本函数前执行)设置——enabled 但 !Secure → 拒启动。
if !SessionCookieSecure {
return fmt.Errorf("%s=true requires SESSION_COOKIE_SECURE=true (+ SESSION_COOKIE_TRUSTED_URL); refusing to start — callback refresh cookie must be Secure", EnvWischoicerSsoEnabled)
}
if err := ValidateWischoicerSsoPublicOrigin(WischoicerSsoPublicOrigin); err != nil {
return fmt.Errorf("%s invalid (WischoicerSSO enabled): %w", EnvWischoicerSsoPublicOrigin, err)
}
if err := ValidateWischoicerSsoAuthorizeURL(WischoicerSsoAuthorizeURL); err != nil {
return fmt.Errorf("%s invalid (WischoicerSSO enabled): %w", EnvWischoicerSsoAuthorizeURL, err)
}
return nil
}

// wischoicerBoolEnv 解析 tri-state bool env(记星 P1-2):空/unset/"false" → false(无错),
// "true" → true,**任何其它非空值 → error**(报变量名 + 拒启动,防 gate 拼写错误静默失效)。
func wischoicerBoolEnv(name string) (bool, error) {
v := strings.TrimSpace(os.Getenv(name))
switch {
case v == "" || strings.EqualFold(v, "false"):
return false, nil
case strings.EqualFold(v, "true"):
return true, nil
default:
return false, fmt.Errorf("%s must be one of true|false (or unset); got %q — refusing to start (防止 gate 拼写错误静默失效)", name, v)
}
}

// ValidateWischoicerSsoPublicOrigin 校验公网 callback 纯 origin(RFC v4 §1.4 N3):
// scheme=https、host 非空、path 仅空或 "/"、无 query/fragment/userinfo。任一不满足即 reject。
func ValidateWischoicerSsoPublicOrigin(raw string) error {
if raw == "" {
return fmt.Errorf("origin must not be empty")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse origin: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("host must not be empty")
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("path must be empty or '/', got %q", u.Path)
}
if u.RawQuery != "" {
return fmt.Errorf("query not allowed in pure origin: %q", u.RawQuery)
}
if u.Fragment != "" {
return fmt.Errorf("fragment not allowed in pure origin: %q", u.Fragment)
}
if u.User != nil {
return fmt.Errorf("userinfo not allowed in pure origin")
}
return nil
}

// ValidateWischoicerSsoAuthorizeURL 校验 C1→C3 跳转目标(RFC v4 §2 N1 + §1.2):
// scheme=https、host 非空、path == 固定 /bff/gateway/user/sso/authorize、无 query/fragment/userinfo。
func ValidateWischoicerSsoAuthorizeURL(raw string) error {
if raw == "" {
return fmt.Errorf("authorize URL must not be empty")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse authorize URL: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("host must not be empty")
}
if u.Path != wischoicerSsoAuthorizeFixedPath {
return fmt.Errorf("path must be %q, got %q", wischoicerSsoAuthorizeFixedPath, u.Path)
}
if u.RawQuery != "" {
return fmt.Errorf("query not allowed: %q", u.RawQuery)
}
if u.Fragment != "" {
return fmt.Errorf("fragment not allowed: %q", u.Fragment)
}
if u.User != nil {
return fmt.Errorf("userinfo not allowed")
}
return nil
}

// WischoicerSsoCallbackURL 用 URL builder 把纯 origin + 固定 callback path + code 拼成绝对
// callback URL(RFC v4 §1.4):`{TrimRight(origin,"/")}/api/sso/wischoicer/callback?code={escape}`。
// 禁字符串拼接用户输入 / 从请求参数取 origin——origin 已 boot 校验为纯 origin。调用方需确保
// WischoicerSsoEnabled=true(即 origin 已校验)。
func WischoicerSsoCallbackURL(code string) string {
return strings.TrimRight(WischoicerSsoPublicOrigin, "/") +
wischoicerSsoCallbackFixedPath + "?code=" + url.QueryEscape(code)
}
Loading