feat(channels): align twitter authorization
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user