mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:48:43 +08:00
fix(grok): 完善密码与SSO授权能力控制
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user