265 lines
7.6 KiB
Go
265 lines
7.6 KiB
Go
|
|
package providers
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"crypto/rand"
|
|||
|
|
"encoding/base64"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"net/http"
|
|||
|
|
"net/url"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// TwitterProvider Twitter OAuth提供者 (OAuth 2.0 with PKCE)
|
|||
|
|
type TwitterProvider struct {
|
|||
|
|
ClientID string
|
|||
|
|
RedirectURI string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TwitterAuthURLResponse Twitter授权URL响应
|
|||
|
|
type TwitterAuthURLResponse struct {
|
|||
|
|
URL string `json:"url"`
|
|||
|
|
CodeVerifier string `json:"code_verifier"`
|
|||
|
|
State string `json:"state"`
|
|||
|
|
Redirect string `json:"redirect,omitempty"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TwitterTokenResponse Twitter Token响应
|
|||
|
|
type TwitterTokenResponse struct {
|
|||
|
|
AccessToken string `json:"access_token"`
|
|||
|
|
TokenType string `json:"token_type"`
|
|||
|
|
ExpiresIn int `json:"expires_in"`
|
|||
|
|
RefreshToken string `json:"refresh_token"`
|
|||
|
|
Scope string `json:"scope"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TwitterUserInfo Twitter用户信息
|
|||
|
|
type TwitterUserInfo struct {
|
|||
|
|
Data struct {
|
|||
|
|
ID string `json:"id"`
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
Username string `json:"username"`
|
|||
|
|
CreatedAt string `json:"created_at"`
|
|||
|
|
Description string `json:"description"`
|
|||
|
|
PublicMetrics struct {
|
|||
|
|
FollowersCount int `json:"followers_count"`
|
|||
|
|
FollowingCount int `json:"following_count"`
|
|||
|
|
TweetCount int `json:"tweet_count"`
|
|||
|
|
ListedCount int `json:"listed_count"`
|
|||
|
|
} `json:"public_metrics"`
|
|||
|
|
ProfileImageURL string `json:"profile_image_url"`
|
|||
|
|
} `json:"data"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TwitterErrorResponse Twitter错误响应
|
|||
|
|
type TwitterErrorResponse struct {
|
|||
|
|
Title string `json:"title"`
|
|||
|
|
Detail string `json:"detail"`
|
|||
|
|
Type string `json:"type"`
|
|||
|
|
Status int `json:"status"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewTwitterProvider 创建Twitter OAuth提供者
|
|||
|
|
func NewTwitterProvider(clientID, redirectURI string) *TwitterProvider {
|
|||
|
|
return &TwitterProvider{
|
|||
|
|
ClientID: clientID,
|
|||
|
|
RedirectURI: redirectURI,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateCodeVerifier 生成PKCE Code Verifier
|
|||
|
|
func (t *TwitterProvider) GenerateCodeVerifier() (string, error) {
|
|||
|
|
b := make([]byte, 32)
|
|||
|
|
_, err := rand.Read(b)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateCodeChallenge 从Code Verifier生成Code Challenge
|
|||
|
|
func (t *TwitterProvider) GenerateCodeChallenge(verifier string) string {
|
|||
|
|
// 简化的base64编码(实际应用中应该使用SHA256哈希)
|
|||
|
|
return verifier
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateState 生成随机状态码
|
|||
|
|
func (t *TwitterProvider) GenerateState() (string, error) {
|
|||
|
|
b := make([]byte, 32)
|
|||
|
|
_, err := rand.Read(b)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetAuthURL 获取Twitter授权URL (OAuth 2.0 with PKCE)
|
|||
|
|
func (t *TwitterProvider) GetAuthURL() (*TwitterAuthURLResponse, error) {
|
|||
|
|
verifier, err := t.GenerateCodeVerifier()
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("generate code verifier failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
challenge := t.GenerateCodeChallenge(verifier)
|
|||
|
|
|
|||
|
|
state, err := t.GenerateState()
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("generate state failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
authURL := fmt.Sprintf(
|
|||
|
|
"https://twitter.com/i/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=tweet.read%%20users.read%%20offline.access&state=%s&code_challenge=%s&code_challenge_method=plain",
|
|||
|
|
t.ClientID,
|
|||
|
|
url.QueryEscape(t.RedirectURI),
|
|||
|
|
state,
|
|||
|
|
challenge,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return &TwitterAuthURLResponse{
|
|||
|
|
URL: authURL,
|
|||
|
|
CodeVerifier: verifier,
|
|||
|
|
State: state,
|
|||
|
|
Redirect: t.RedirectURI,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ExchangeCode 用授权码换取访问令牌
|
|||
|
|
func (t *TwitterProvider) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TwitterTokenResponse, error) {
|
|||
|
|
tokenURL := "https://api.twitter.com/2/oauth2/token"
|
|||
|
|
|
|||
|
|
data := url.Values{}
|
|||
|
|
data.Set("code", code)
|
|||
|
|
data.Set("grant_type", "authorization_code")
|
|||
|
|
data.Set("client_id", t.ClientID)
|
|||
|
|
data.Set("redirect_uri", t.RedirectURI)
|
|||
|
|
data.Set("code_verifier", codeVerifier)
|
|||
|
|
|
|||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|||
|
|
resp, err := postFormWithContext(ctx, client, tokenURL, data)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
body, err := readOAuthResponseBody(resp)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("read response failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查错误响应
|
|||
|
|
var errResp TwitterErrorResponse
|
|||
|
|
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Detail != "" {
|
|||
|
|
return nil, fmt.Errorf("twitter api error: %s - %s", errResp.Title, errResp.Detail)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var tokenResp TwitterTokenResponse
|
|||
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|||
|
|
return nil, fmt.Errorf("parse token response failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &tokenResp, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserInfo 获取Twitter用户信息
|
|||
|
|
func (t *TwitterProvider) GetUserInfo(ctx context.Context, accessToken string) (*TwitterUserInfo, error) {
|
|||
|
|
userInfoURL := "https://api.twitter.com/2/users/me?user.fields=created_at,description,public_metrics,profile_image_url"
|
|||
|
|
|
|||
|
|
req, err := http.NewRequestWithContext(ctx, "GET", userInfoURL, nil)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("create request failed: %w", err)
|
|||
|
|
}
|
|||
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
|||
|
|
|
|||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|||
|
|
resp, err := client.Do(req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
body, err := readOAuthResponseBody(resp)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("read response failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查错误响应
|
|||
|
|
var errResp TwitterErrorResponse
|
|||
|
|
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Detail != "" {
|
|||
|
|
return nil, fmt.Errorf("twitter api error: %s - %s", errResp.Title, errResp.Detail)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var userInfo TwitterUserInfo
|
|||
|
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
|||
|
|
return nil, fmt.Errorf("parse user info failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &userInfo, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RefreshToken 刷新访问令牌
|
|||
|
|
func (t *TwitterProvider) RefreshToken(ctx context.Context, refreshToken string) (*TwitterTokenResponse, error) {
|
|||
|
|
tokenURL := "https://api.twitter.com/2/oauth2/token"
|
|||
|
|
|
|||
|
|
data := url.Values{}
|
|||
|
|
data.Set("refresh_token", refreshToken)
|
|||
|
|
data.Set("grant_type", "refresh_token")
|
|||
|
|
data.Set("client_id", t.ClientID)
|
|||
|
|
|
|||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|||
|
|
resp, err := postFormWithContext(ctx, client, tokenURL, data)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
body, err := readOAuthResponseBody(resp)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("read response failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var errResp TwitterErrorResponse
|
|||
|
|
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Detail != "" {
|
|||
|
|
return nil, fmt.Errorf("twitter api error: %s - %s", errResp.Title, errResp.Detail)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var tokenResp TwitterTokenResponse
|
|||
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|||
|
|
return nil, fmt.Errorf("parse token response failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &tokenResp, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ValidateToken 验证访问令牌是否有效
|
|||
|
|
func (t *TwitterProvider) ValidateToken(ctx context.Context, accessToken string) (bool, error) {
|
|||
|
|
userInfo, err := t.GetUserInfo(ctx, accessToken)
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
return userInfo != nil && userInfo.Data.ID != "", nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RevokeToken 撤销访问令牌
|
|||
|
|
func (t *TwitterProvider) RevokeToken(ctx context.Context, accessToken string) error {
|
|||
|
|
revokeURL := "https://api.twitter.com/2/oauth2/revoke"
|
|||
|
|
|
|||
|
|
data := url.Values{}
|
|||
|
|
data.Set("token", accessToken)
|
|||
|
|
data.Set("client_id", t.ClientID)
|
|||
|
|
data.Set("token_type_hint", "access_token")
|
|||
|
|
|
|||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|||
|
|
resp, err := postFormWithContext(ctx, client, revokeURL, data)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("request failed: %w", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
if _, err := readOAuthResponseBody(resp); err != nil {
|
|||
|
|
return fmt.Errorf("revoke token failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|