feat(channels): align twitter authorization

This commit is contained in:
2026-06-06 18:29:35 +08:00
parent 0f2d64c8f6
commit 5b143b6da7
7 changed files with 294 additions and 8 deletions
@@ -0,0 +1,132 @@
package v1
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTwitterAuthorizationRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.RedirectTrailingSlash = false
handler := NewTwitterChannelHandler(nil, nil, nil, nil)
r.POST("/api/v1/accounts/:account_id/twitter/authorization", handler.ChatwootAuthorization)
return r
}
func TestTwitterAuthorization_BadAccountID(t *testing.T) {
r := setupTwitterAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/abc/twitter/authorization", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
errBody := resp["error"].(map[string]interface{})
assert.Contains(t, errBody["message"], "invalid account_id")
}
func TestTwitterAuthorization_ReturnsChatwootPayload(t *testing.T) {
t.Setenv("TWITTER_CONSUMER_KEY", "twitter-key")
t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret")
t.Setenv("TWITTER_OAUTH_REQUEST_TOKEN_URL", "https://oauth.example.test/request_token")
t.Setenv("TWITTER_API_BASE_URL", "https://api.twitter.test")
t.Setenv("FRONTEND_URL", "https://app.example.test/")
originalClient := twitterAuthorizationHTTPClient
twitterAuthorizationHTTPClient = &http.Client{Transport: twitterAuthorizationRoundTripFunc(func(req *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodPost, req.Method)
assert.Equal(t, "https://oauth.example.test/request_token", req.URL.String())
authHeader := req.Header.Get("Authorization")
assert.Contains(t, authHeader, "OAuth ")
assert.Contains(t, authHeader, `oauth_consumer_key="twitter-key"`)
assert.Contains(t, authHeader, "oauth_callback=")
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("oauth_token=request-token&oauth_token_secret=request-secret")), Header: http.Header{}}, nil
})}
t.Cleanup(func() { twitterAuthorizationHTTPClient = originalClient })
r := setupTwitterAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/twitter/authorization", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, true, resp["success"])
assert.Equal(t, "https://api.twitter.test/oauth/authorize?oauth_token=request-token", resp["url"])
}
func TestTwitterAuthorization_SignedStateMatchesCallback(t *testing.T) {
t.Setenv("TWITTER_CONSUMER_KEY", "twitter-key")
t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret")
t.Setenv("TWITTER_OAUTH_REQUEST_TOKEN_URL", "https://oauth.example.test/request_token")
t.Setenv("FRONTEND_URL", "https://app.example.test")
originalClient := twitterAuthorizationHTTPClient
var callbackURL string
twitterAuthorizationHTTPClient = &http.Client{Transport: twitterAuthorizationRoundTripFunc(func(req *http.Request) (*http.Response, error) {
authHeader := req.Header.Get("Authorization")
for _, part := range strings.Split(strings.TrimPrefix(authHeader, "OAuth "), ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "oauth_callback=") {
callbackURL = strings.Trim(strings.TrimPrefix(part, "oauth_callback="), `"`)
decoded, err := url.QueryUnescape(callbackURL)
require.NoError(t, err)
callbackURL = decoded
}
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("oauth_token=request-token")), Header: http.Header{}}, nil
})}
t.Cleanup(func() { twitterAuthorizationHTTPClient = originalClient })
r := setupTwitterAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/twitter/authorization", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
parsed, err := url.Parse(callbackURL)
require.NoError(t, err)
assert.Equal(t, "https://app.example.test/twitter/callback", parsed.Scheme+"://"+parsed.Host+parsed.Path)
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(parsed.Query().Get("state"), claims, func(token *jwt.Token) (any, error) {
return []byte("twitter-secret"), nil
})
require.NoError(t, err)
require.True(t, token.Valid)
assert.Equal(t, float64(42), claims["sub"])
}
func TestTwitterAuthorization_NotConfigured(t *testing.T) {
t.Setenv("TWITTER_CONSUMER_KEY", "")
t.Setenv("TWITTER_CONSUMER_SECRET", "")
r := setupTwitterAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/twitter/authorization", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, false, resp["success"])
}
type twitterAuthorizationRoundTripFunc func(*http.Request) (*http.Response, error)
func (f twitterAuthorizationRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
@@ -25,10 +25,24 @@ package v1
// - GET /api/v1/accounts/:id/twitter/webhooks → List registered webhooks
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
twitterchannel "github.com/gochat/gochat/internal/channel/twitter"
"github.com/gochat/gochat/internal/model"
@@ -36,8 +50,11 @@ import (
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
var twitterAuthorizationHTTPClient = http.DefaultClient
// TwitterChannelHandler handles Twitter/X channel management.
type TwitterChannelHandler struct {
twService *service.ChannelTwitterService
@@ -95,6 +112,136 @@ func (h *TwitterChannelHandler) Authorization(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"authorization_url": authURL})
}
// ChatwootAuthorization creates a Twitter OAuth1 authorization URL.
// POST /api/v1/accounts/:account_id/twitter/authorization
func (h *TwitterChannelHandler) ChatwootAuthorization(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
redirectURL, err := buildTwitterChatwootAuthorizationURL(c.Request.Context(), accountID)
if err != nil {
applogger.L().Errorf("Failed to build Twitter authorization URL: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
}
func buildTwitterChatwootAuthorizationURL(ctx context.Context, accountID uint) (string, error) {
consumerKey := strings.TrimSpace(os.Getenv("TWITTER_CONSUMER_KEY"))
consumerSecret := strings.TrimSpace(os.Getenv("TWITTER_CONSUMER_SECRET"))
if consumerKey == "" || consumerSecret == "" {
return "", fmt.Errorf("Twitter OAuth is not configured")
}
state, err := signedTwitterState(accountID, consumerSecret)
if err != nil {
return "", err
}
callbackURL := strings.TrimRight(envOrDefaultV1("FRONTEND_URL", "http://localhost:3000"), "/") + "/twitter/callback?state=" + url.QueryEscape(state)
requestTokenURL := envOrDefaultV1("TWITTER_OAUTH_REQUEST_TOKEN_URL", "https://api.twitter.com/oauth/request_token")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestTokenURL, nil)
if err != nil {
return "", err
}
authHeader, err := twitterOAuth1Header(http.MethodPost, requestTokenURL, consumerKey, consumerSecret, callbackURL)
if err != nil {
return "", err
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("Accept", "application/x-www-form-urlencoded")
resp, err := twitterAuthorizationHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
_, _ = io.Copy(io.Discard, resp.Body)
return "", fmt.Errorf("twitter request token failed: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
values, err := url.ParseQuery(string(body))
if err != nil {
return "", err
}
oauthToken := strings.TrimSpace(values.Get("oauth_token"))
if oauthToken == "" {
return "", fmt.Errorf("twitter request token missing oauth_token")
}
authorizeBase := strings.TrimRight(envOrDefaultV1("TWITTER_API_BASE_URL", "https://api.twitter.com"), "/")
return authorizeBase + "/oauth/authorize?oauth_token=" + url.QueryEscape(oauthToken), nil
}
func signedTwitterState(accountID uint, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": accountID,
"iat": time.Now().Unix(),
})
return token.SignedString([]byte(secret))
}
func twitterOAuth1Header(method, rawURL, consumerKey, consumerSecret, callbackURL string) (string, error) {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return "", err
}
params := map[string]string{
"oauth_callback": callbackURL,
"oauth_consumer_key": consumerKey,
"oauth_nonce": hex.EncodeToString(nonceBytes),
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": strconv.FormatInt(time.Now().Unix(), 10),
"oauth_version": "1.0",
}
params["oauth_signature"] = oauth1Signature(method, rawURL, params, consumerSecret)
keys := make([]string, 0, len(params))
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf(`%s="%s"`, oauthPercentEncode(key), oauthPercentEncode(params[key])))
}
return "OAuth " + strings.Join(parts, ", "), nil
}
func oauth1Signature(method, rawURL string, params map[string]string, consumerSecret string) string {
keys := make([]string, 0, len(params))
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
encodedParams := make([]string, 0, len(keys))
for _, key := range keys {
encodedParams = append(encodedParams, oauthPercentEncode(key)+"="+oauthPercentEncode(params[key]))
}
base := strings.ToUpper(method) + "&" + oauthPercentEncode(rawURL) + "&" + oauthPercentEncode(strings.Join(encodedParams, "&"))
mac := hmac.New(sha1.New, []byte(oauthPercentEncode(consumerSecret)+"&"))
_, _ = mac.Write([]byte(base))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func oauthPercentEncode(value string) string {
return strings.ReplaceAll(url.QueryEscape(value), "+", "%20")
}
func envOrDefaultV1(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
// === OAuth Callback ===
// TwitterOAuthCallbackRequest is the DTO for Twitter OAuth callback.