核心功能: - 实现基于 Telegram Inline Button 交互的后台面板与用户端 - 支持通过账密登录和 RT (Refresh Token) 方式添加 ChatGPT Team 账号 - 支持管理、拉取和删除待处理邀请,支持一键清空多余邀请 - 支持按剩余容量自动生成邀请兑换码,支持分页查看与一键清空未使用兑换码 - 随机邀请功能:成功拉人后自动核销兑换码 - 定时检测 Token 状态,实现自动续订/刷新并拦截封禁账号 (处理 401/402 错误) 系统与配置: - 使用 PostgreSQL 数据库管理账号、邀请和兑换记录 - 支持在端内动态添加、移除管理员 - 完善 Docker 部署配置与 .gitignore 规则
234 lines
6.2 KiB
Go
234 lines
6.2 KiB
Go
package chatgpt
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// OAuthSession holds PKCE session data for an in-progress OAuth login.
|
||
type OAuthSession struct {
|
||
CodeVerifier string
|
||
State string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
// OAuthResult holds the tokens and account info after a successful OAuth exchange.
|
||
type OAuthResult struct {
|
||
AccessToken string
|
||
RefreshToken string
|
||
Email string
|
||
AccountID string
|
||
Name string
|
||
PlanType string
|
||
}
|
||
|
||
// OAuthManager manages PKCE sessions for the manual URL-paste OAuth flow.
|
||
type OAuthManager struct {
|
||
client *Client
|
||
sessions map[string]*OAuthSession // state -> session
|
||
mu sync.Mutex
|
||
}
|
||
|
||
// NewOAuthManager creates a new OAuth manager.
|
||
func NewOAuthManager(client *Client) *OAuthManager {
|
||
return &OAuthManager{
|
||
client: client,
|
||
sessions: make(map[string]*OAuthSession),
|
||
}
|
||
}
|
||
|
||
// GenerateAuthURL creates an OpenAI OAuth authorization URL with PKCE.
|
||
// The redirect_uri points to localhost which won't load — user copies the URL from browser.
|
||
func (m *OAuthManager) GenerateAuthURL() (authURL, state string, err error) {
|
||
// Generate PKCE code verifier.
|
||
verifierBytes := make([]byte, 64)
|
||
if _, err := rand.Read(verifierBytes); err != nil {
|
||
return "", "", fmt.Errorf("生成 PKCE 失败: %w", err)
|
||
}
|
||
codeVerifier := hex.EncodeToString(verifierBytes)
|
||
|
||
hash := sha256.Sum256([]byte(codeVerifier))
|
||
codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
||
|
||
// Generate state.
|
||
stateBytes := make([]byte, 32)
|
||
if _, err := rand.Read(stateBytes); err != nil {
|
||
return "", "", fmt.Errorf("生成 state 失败: %w", err)
|
||
}
|
||
state = hex.EncodeToString(stateBytes)
|
||
|
||
// Store session.
|
||
m.mu.Lock()
|
||
m.sessions[state] = &OAuthSession{
|
||
CodeVerifier: codeVerifier,
|
||
State: state,
|
||
CreatedAt: time.Now(),
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
// Build auth URL — redirect to localhost, user will copy the URL.
|
||
redirectURI := "http://localhost:1455/auth/callback"
|
||
params := url.Values{}
|
||
params.Set("response_type", "code")
|
||
params.Set("client_id", openaiClientID)
|
||
params.Set("redirect_uri", redirectURI)
|
||
params.Set("scope", "openid profile email offline_access")
|
||
params.Set("code_challenge", codeChallenge)
|
||
params.Set("code_challenge_method", "S256")
|
||
params.Set("state", state)
|
||
params.Set("id_token_add_organizations", "true")
|
||
params.Set("codex_cli_simplified_flow", "true")
|
||
|
||
authURL = fmt.Sprintf("https://auth.openai.com/oauth/authorize?%s", params.Encode())
|
||
return authURL, state, nil
|
||
}
|
||
|
||
// ExchangeCallbackURL parses the pasted callback URL to extract code and state,
|
||
// then exchanges the authorization code for tokens.
|
||
func (m *OAuthManager) ExchangeCallbackURL(callbackURL string) (*OAuthResult, error) {
|
||
callbackURL = strings.TrimSpace(callbackURL)
|
||
|
||
parsed, err := url.Parse(callbackURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("URL 格式错误: %w", err)
|
||
}
|
||
|
||
code := parsed.Query().Get("code")
|
||
state := parsed.Query().Get("state")
|
||
|
||
if code == "" {
|
||
errMsg := parsed.Query().Get("error_description")
|
||
if errMsg == "" {
|
||
errMsg = parsed.Query().Get("error")
|
||
}
|
||
if errMsg == "" {
|
||
errMsg = "回调 URL 中未找到 code 参数"
|
||
}
|
||
return nil, fmt.Errorf("%s", errMsg)
|
||
}
|
||
|
||
if state == "" {
|
||
return nil, fmt.Errorf("回调 URL 中未找到 state 参数")
|
||
}
|
||
|
||
// Look up the session.
|
||
m.mu.Lock()
|
||
session, ok := m.sessions[state]
|
||
if ok {
|
||
delete(m.sessions, state)
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
if !ok {
|
||
return nil, fmt.Errorf("会话已过期或无效,请重新使用 /login")
|
||
}
|
||
|
||
// Check if session is expired (10 minutes).
|
||
if time.Since(session.CreatedAt) > 10*time.Minute {
|
||
return nil, fmt.Errorf("登录会话已过期(超过10分钟),请重新使用 /login")
|
||
}
|
||
|
||
// Exchange code for tokens.
|
||
return m.exchangeCode(code, session.CodeVerifier)
|
||
}
|
||
|
||
func (m *OAuthManager) exchangeCode(code, codeVerifier string) (*OAuthResult, error) {
|
||
redirectURI := "http://localhost:1455/auth/callback"
|
||
|
||
form := url.Values{}
|
||
form.Set("grant_type", "authorization_code")
|
||
form.Set("code", code)
|
||
form.Set("redirect_uri", redirectURI)
|
||
form.Set("client_id", openaiClientID)
|
||
form.Set("code_verifier", codeVerifier)
|
||
|
||
req, err := http.NewRequest("POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := m.client.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("网络错误: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("交换授权码失败 (HTTP %d): %s", resp.StatusCode, truncate(string(body), 300))
|
||
}
|
||
|
||
var tokenResp struct {
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token"`
|
||
IDToken string `json:"id_token"`
|
||
}
|
||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||
return nil, fmt.Errorf("解析 token 响应失败: %w", err)
|
||
}
|
||
|
||
if tokenResp.AccessToken == "" {
|
||
return nil, fmt.Errorf("未返回有效的 access token")
|
||
}
|
||
|
||
result := &OAuthResult{
|
||
AccessToken: tokenResp.AccessToken,
|
||
RefreshToken: tokenResp.RefreshToken,
|
||
}
|
||
|
||
// Decode ID token for user info.
|
||
if tokenResp.IDToken != "" {
|
||
claims, err := decodeJWTPayload(tokenResp.IDToken)
|
||
if err == nil {
|
||
result.Email, _ = claims["email"].(string)
|
||
result.Name, _ = claims["name"].(string)
|
||
if authClaims, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
|
||
result.AccountID, _ = authClaims["chatgpt_account_id"].(string)
|
||
result.PlanType, _ = authClaims["chatgpt_plan_type"].(string)
|
||
}
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func decodeJWTPayload(token string) (map[string]interface{}, error) {
|
||
parts := strings.Split(token, ".")
|
||
if len(parts) != 3 {
|
||
return nil, fmt.Errorf("invalid JWT format")
|
||
}
|
||
|
||
payload := parts[1]
|
||
switch len(payload) % 4 {
|
||
case 2:
|
||
payload += "=="
|
||
case 3:
|
||
payload += "="
|
||
}
|
||
|
||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
decoded, err = base64.RawURLEncoding.DecodeString(parts[1])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
var claims map[string]interface{}
|
||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||
return nil, err
|
||
}
|
||
return claims, nil
|
||
}
|