feat: 初始化 ChatGPT Team 管理机器人
核心功能: - 实现基于 Telegram Inline Button 交互的后台面板与用户端 - 支持通过账密登录和 RT (Refresh Token) 方式添加 ChatGPT Team 账号 - 支持管理、拉取和删除待处理邀请,支持一键清空多余邀请 - 支持按剩余容量自动生成邀请兑换码,支持分页查看与一键清空未使用兑换码 - 随机邀请功能:成功拉人后自动核销兑换码 - 定时检测 Token 状态,实现自动续订/刷新并拦截封禁账号 (处理 401/402 错误) 系统与配置: - 使用 PostgreSQL 数据库管理账号、邀请和兑换记录 - 支持在端内动态添加、移除管理员 - 完善 Docker 部署配置与 .gitignore 规则
This commit is contained in:
233
internal/chatgpt/oauth.go
Normal file
233
internal/chatgpt/oauth.go
Normal file
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user