fix(grok): 完善密码与SSO授权能力控制

This commit is contained in:
IanShaw027
2026-08-07 14:13:07 +08:00
parent 2413441b5f
commit d0930c4bdb
12 changed files with 122 additions and 10 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
grokOAuthClient := repository.NewGrokOAuthClient()
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig)
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
+4
View File
@@ -1038,6 +1038,9 @@ type GatewayConfig struct {
// - free_quota_window_hours: local usage rolling window length in hours.
// - free_quota_stats_cache_seconds: bound hot-path aggregate query frequency (0 disables cache).
type GatewayGrokConfig struct {
// PasswordAuthEnabled gates the experimental admin-only password flow.
// It is disabled by default because captcha solving uses an external service.
PasswordAuthEnabled bool `mapstructure:"password_auth_enabled"`
// FreeQuotaSoftGateEnabled enables a local rolling-window scheduling guard
// for explicitly free Grok OAuth accounts only.
FreeQuotaSoftGateEnabled bool `mapstructure:"free_quota_soft_gate_enabled"`
@@ -2339,6 +2342,7 @@ func setDefaults() {
// Grok free-tier local soft gate (scheduler-only; admin QueryQuota does not use this).
// Enabled by default because free detection requires an explicit free tier marker.
viper.SetDefault("gateway.grok.free_quota_soft_gate_enabled", true)
viper.SetDefault("gateway.grok.password_auth_enabled", false)
viper.SetDefault("gateway.grok.free_quota_token_limit", int64(2_000_000))
viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95)
viper.SetDefault("gateway.grok.free_quota_window_hours", 24)
+1
View File
@@ -543,6 +543,7 @@ func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) {
cfg, err := Load()
require.NoError(t, err)
require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled)
require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled)
require.Equal(t, int64(2_000_000), cfg.Gateway.Grok.FreeQuotaTokenLimit)
require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent)
@@ -47,6 +47,10 @@ type GrokGenerateAuthURLRequest struct {
RedirectURI string `json:"redirect_uri"`
}
func (h *GrokOAuthHandler) GetCapabilities(c *gin.Context) {
response.Success(c, h.grokOAuthService.GetCapabilities())
}
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
var req GrokGenerateAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -235,7 +236,9 @@ func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *tes
gin.SetMode(gin.TestMode)
oauthClient := &grokOAuthHandlerClient{}
oauthService := service.NewGrokOAuthService(nil, oauthClient)
cfg := &config.Config{}
cfg.Gateway.Grok.PasswordAuthEnabled = true
oauthService := service.NewGrokOAuthService(nil, oauthClient, cfg)
defer oauthService.Stop()
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
@@ -254,6 +257,21 @@ func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *tes
require.NotContains(t, rec.Body.String(), "sso-from-password")
}
func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
oauthService := service.NewGrokOAuthService(nil, &grokOAuthHandlerClient{})
defer oauthService.Stop()
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
router := gin.New()
router.GET("/api/v1/admin/grok/oauth/capabilities", handler.GetCapabilities)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/oauth/capabilities", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"password_auth_enabled":false`)
}
func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
+1
View File
@@ -463,6 +463,7 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
grok := admin.Group("/grok")
{
grok.GET("/oauth/capabilities", h.Admin.GrokOAuth.GetCapabilities)
grok.POST("/oauth/auth-url", h.Admin.GrokOAuth.GenerateAuthURL)
grok.POST("/oauth/exchange-code", h.Admin.GrokOAuth.ExchangeCode)
grok.POST("/oauth/refresh-token", h.Admin.GrokOAuth.RefreshToken)
+23 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
@@ -18,14 +19,31 @@ type GrokOAuthService struct {
sessionStore *xai.SessionStore
proxyRepo ProxyRepository
oauthClient GrokOAuthClient
config *config.Config
}
func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient) *GrokOAuthService {
return &GrokOAuthService{
func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, configs ...*config.Config) *GrokOAuthService {
service := &GrokOAuthService{
sessionStore: xai.NewSessionStore(),
proxyRepo: proxyRepo,
oauthClient: oauthClient,
}
if len(configs) > 0 {
service.config = configs[0]
}
return service
}
type GrokOAuthCapabilities struct {
PasswordAuthEnabled bool `json:"password_auth_enabled"`
}
func (s *GrokOAuthService) GetCapabilities() GrokOAuthCapabilities {
return GrokOAuthCapabilities{PasswordAuthEnabled: s.passwordAuthEnabled()}
}
func (s *GrokOAuthService) passwordAuthEnabled() bool {
return s.config != nil && s.config.Gateway.Grok.PasswordAuthEnabled
}
type GrokAuthURLResult struct {
@@ -212,6 +230,9 @@ func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string,
// AuthorizePassword logs in with email/password, converts the resulting SSO cookie
// to Build OAuth, and returns OAuth tokens only. Password and raw SSO are never persisted.
func (s *GrokOAuthService) AuthorizePassword(ctx context.Context, email, password string, proxyID *int64) (*GrokTokenInfo, error) {
if !s.passwordAuthEnabled() {
return nil, infraerrors.New(http.StatusForbidden, "GROK_OAUTH_PASSWORD_AUTH_DISABLED", "Grok password authorization is disabled")
}
email = strings.TrimSpace(email)
if email == "" {
return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_EMAIL_REQUIRED", "email is required")
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/stretchr/testify/require"
)
@@ -153,7 +154,9 @@ func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T
ExpiresIn: 3600,
},
}
svc := NewGrokOAuthService(nil, client)
cfg := &config.Config{}
cfg.Gateway.Grok.PasswordAuthEnabled = true
svc := NewGrokOAuthService(nil, client, cfg)
defer svc.Stop()
info, err := svc.AuthorizePassword(context.Background(), " user@example.com ", " super-secret ", nil)
@@ -168,6 +171,18 @@ func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T
require.Equal(t, " super-secret ", client.loginPassword, "password bytes must be preserved for upstream login")
}
func TestGrokOAuthServiceAuthorizePasswordDisabledByDefault(t *testing.T) {
client := &grokOAuthClientStub{}
svc := NewGrokOAuthService(nil, client)
defer svc.Stop()
require.False(t, svc.GetCapabilities().PasswordAuthEnabled)
_, err := svc.AuthorizePassword(context.Background(), "user@example.com", "secret", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "GROK_OAUTH_PASSWORD_AUTH_DISABLED")
require.Empty(t, client.loginEmail)
}
func makeGrokOAuthJWT(claims map[string]any) string {
payload, _ := json.Marshal(claims)
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
+5 -1
View File
@@ -15,6 +15,10 @@ import (
"go.uber.org/zap"
)
func ProvideGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, cfg *config.Config) *GrokOAuthService {
return NewGrokOAuthService(proxyRepo, oauthClient, cfg)
}
// BuildInfo contains build information
type BuildInfo struct {
Version string
@@ -762,7 +766,7 @@ var ProviderSet = wire.NewSet(
wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)),
NewOAuthService,
ProvideOpenAIOAuthService,
NewGrokOAuthService,
ProvideGrokOAuthService,
wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)),
NewGeminiOAuthService,
NewGeminiQuotaService,
+4
View File
@@ -444,6 +444,10 @@ gateway:
# Enabled by default because free detection requires an explicit subscription_tier/plan_type of "free".
# Stats/query failures fail open so DB issues do not block all Grok traffic.
grok:
# Experimental admin-only email/password authorization. Disabled by default.
# Requires YESCAPTCHA_CLIENT_KEY (or legacy YESCAPTCHA_API_KEY) in the server environment.
# TurnstileTaskProxyless uses solver egress independent from the optional account proxy.
password_auth_enabled: false
free_quota_soft_gate_enabled: true
free_quota_token_limit: 2000000
free_quota_soft_gate_percent: 95
+18 -2
View File
@@ -19,6 +19,17 @@ export interface GrokAuthUrlRequest {
redirect_uri?: string
}
export interface GrokOAuthCapabilities {
password_auth_enabled: boolean
}
const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000
export async function getCapabilities(): Promise<GrokOAuthCapabilities> {
const { data } = await apiClient.get<GrokOAuthCapabilities>('/admin/grok/oauth/capabilities')
return data
}
export interface GrokExchangeCodeRequest {
session_id: string
state: string
@@ -177,7 +188,9 @@ export async function validateSSOToken(
): Promise<GrokTokenInfo> {
const payload: Record<string, unknown> = { sso_token: ssoToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/sso-token', payload)
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/sso-token', payload, {
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
})
return data
}
@@ -196,12 +209,15 @@ export async function authorizePassword(
const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : ''
const payload: Record<string, unknown> = { email, password }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/password', payload)
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/password', payload, {
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
})
return data
}
export default {
generateAuthUrl,
getCapabilities,
exchangeCode,
refreshGrokToken,
queryQuota,
@@ -59,7 +59,7 @@
t(getOAuthKey('ssoCookieAuth'))
}}</span>
</label>
<label v-if="showEmailPasswordOption" class="flex cursor-pointer items-center gap-2">
<label v-if="emailPasswordOptionEnabled" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
@@ -900,6 +900,7 @@ import { useClipboard } from '@/composables/useClipboard'
import Icon from '@/components/icons/Icon.vue'
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
import type { AccountPlatform } from '@/types'
import { adminAPI } from '@/api/admin'
interface Props {
addMethod: AddMethod
@@ -975,6 +976,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const passwordAuthEnabled = ref(false)
const emailPasswordOptionEnabled = computed(
() => props.showEmailPasswordOption && props.platform === 'grok' && passwordAuthEnabled.value
)
const showLocalCallbackNotice = computed(() => props.platform === 'openai' || props.platform === 'grok')
@@ -1021,6 +1026,25 @@ const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
watch(
() => [props.platform, props.showEmailPasswordOption] as const,
async ([platform, requested]) => {
passwordAuthEnabled.value = false
if (platform !== 'grok' || !requested) return
try {
const capabilities = await adminAPI.grok.getCapabilities()
passwordAuthEnabled.value = capabilities.password_auth_enabled
} catch {
// Fail closed; the backend enforces the same capability.
}
},
{ immediate: true }
)
watch(emailPasswordOptionEnabled, (enabled) => {
if (!enabled && inputMethod.value === 'email_password') inputMethod.value = 'manual'
})
// Computed: show method selection only when there is something to choose.
const methodOptionCount = computed(() => [
props.showManualOption,
@@ -1033,7 +1057,7 @@ const methodOptionCount = computed(() => [
props.showAgentIdentityOption,
props.showCodexPatOption,
props.showSsoOption,
props.showEmailPasswordOption
emailPasswordOptionEnabled.value
].filter(Boolean).length)
const showMethodSelection = computed(() => methodOptionCount.value > 1)