cmd/faucet: use Twitter API instead of scraping webpage (#21850)

This PR adds support for using Twitter API to query the tweet and author details. There are two reasons behind this change:

- Twitter will be deprecating the legacy website on 15th December. The current method is expected to stop working then.
- More importantly, the current system uses Twitter handle for spam protection but the Twitter handle can be changed via automated calls. This allows bots to use the same tweet to withdraw funds infinite times as long as they keep changing their handle between every request. The Rinkeby as well as the Goerli faucet are being actively drained via this method. This PR changes the spam protection to be based on Twitter IDs instead of usernames. A user can not change their Twitter ID.
This commit is contained in:
Mudit Gupta 2020-12-11 15:05:39 +05:30 committed by GitHub
parent 62dc59c2bd
commit b47f4ca5cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 110 additions and 13 deletions

@ -83,6 +83,8 @@ var (
noauthFlag = flag.Bool("noauth", false, "Enables funding requests without authentication") noauthFlag = flag.Bool("noauth", false, "Enables funding requests without authentication")
logFlag = flag.Int("loglevel", 3, "Log level to use for Ethereum and the faucet") logFlag = flag.Int("loglevel", 3, "Log level to use for Ethereum and the faucet")
twitterBearerToken = flag.String("twitter.token", "", "Twitter bearer token to authenticate with the twitter API")
) )
var ( var (
@ -443,6 +445,7 @@ func (f *faucet) apiHandler(w http.ResponseWriter, r *http.Request) {
} }
// Retrieve the Ethereum address to fund, the requesting user and a profile picture // Retrieve the Ethereum address to fund, the requesting user and a profile picture
var ( var (
id string
username string username string
avatar string avatar string
address common.Address address common.Address
@ -462,11 +465,13 @@ func (f *faucet) apiHandler(w http.ResponseWriter, r *http.Request) {
} }
continue continue
case strings.HasPrefix(msg.URL, "https://twitter.com/"): case strings.HasPrefix(msg.URL, "https://twitter.com/"):
username, avatar, address, err = authTwitter(msg.URL) id, username, avatar, address, err = authTwitter(msg.URL, *twitterBearerToken)
case strings.HasPrefix(msg.URL, "https://www.facebook.com/"): case strings.HasPrefix(msg.URL, "https://www.facebook.com/"):
username, avatar, address, err = authFacebook(msg.URL) username, avatar, address, err = authFacebook(msg.URL)
id = username
case *noauthFlag: case *noauthFlag:
username, avatar, address, err = authNoAuth(msg.URL) username, avatar, address, err = authNoAuth(msg.URL)
id = username
default: default:
//lint:ignore ST1005 This error is to be displayed in the browser //lint:ignore ST1005 This error is to be displayed in the browser
err = errors.New("Something funky happened, please open an issue at https://github.com/ethereum/go-ethereum/issues") err = errors.New("Something funky happened, please open an issue at https://github.com/ethereum/go-ethereum/issues")
@ -486,7 +491,7 @@ func (f *faucet) apiHandler(w http.ResponseWriter, r *http.Request) {
fund bool fund bool
timeout time.Time timeout time.Time
) )
if timeout = f.timeouts[username]; time.Now().After(timeout) { if timeout = f.timeouts[id]; time.Now().After(timeout) {
// User wasn't funded recently, create the funding transaction // User wasn't funded recently, create the funding transaction
amount := new(big.Int).Mul(big.NewInt(int64(*payoutFlag)), ether) amount := new(big.Int).Mul(big.NewInt(int64(*payoutFlag)), ether)
amount = new(big.Int).Mul(amount, new(big.Int).Exp(big.NewInt(5), big.NewInt(int64(msg.Tier)), nil)) amount = new(big.Int).Mul(amount, new(big.Int).Exp(big.NewInt(5), big.NewInt(int64(msg.Tier)), nil))
@ -520,7 +525,7 @@ func (f *faucet) apiHandler(w http.ResponseWriter, r *http.Request) {
timeout := time.Duration(*minutesFlag*int(math.Pow(3, float64(msg.Tier)))) * time.Minute timeout := time.Duration(*minutesFlag*int(math.Pow(3, float64(msg.Tier)))) * time.Minute
grace := timeout / 288 // 24h timeout => 5m grace grace := timeout / 288 // 24h timeout => 5m grace
f.timeouts[username] = time.Now().Add(timeout - grace) f.timeouts[id] = time.Now().Add(timeout - grace)
fund = true fund = true
} }
f.lock.Unlock() f.lock.Unlock()
@ -684,23 +689,32 @@ func sendSuccess(conn *websocket.Conn, msg string) error {
} }
// authTwitter tries to authenticate a faucet request using Twitter posts, returning // authTwitter tries to authenticate a faucet request using Twitter posts, returning
// the username, avatar URL and Ethereum address to fund on success. // the uniqueness identifier (user id/username), username, avatar URL and Ethereum address to fund on success.
func authTwitter(url string) (string, string, common.Address, error) { func authTwitter(url string, token string) (string, string, string, common.Address, error) {
// Ensure the user specified a meaningful URL, no fancy nonsense // Ensure the user specified a meaningful URL, no fancy nonsense
parts := strings.Split(url, "/") parts := strings.Split(url, "/")
if len(parts) < 4 || parts[len(parts)-2] != "status" { if len(parts) < 4 || parts[len(parts)-2] != "status" {
//lint:ignore ST1005 This error is to be displayed in the browser //lint:ignore ST1005 This error is to be displayed in the browser
return "", "", common.Address{}, errors.New("Invalid Twitter status URL") return "", "", "", common.Address{}, errors.New("Invalid Twitter status URL")
} }
// Twitter's API isn't really friendly with direct links. Still, we don't
// want to do ask read permissions from users, so just load the public posts // Twitter's API isn't really friendly with direct links.
// It is restricted to 300 queries / 15 minute with an app api key.
// Anything more will require read only authorization from the users and that we want to avoid.
// If twitter bearer token is provided, use the twitter api
if token != "" {
return authTwitterWithToken(parts[len(parts)-1], token)
}
// Twiter API token isn't provided so we just load the public posts
// and scrape it for the Ethereum address and profile URL. We need to load // and scrape it for the Ethereum address and profile URL. We need to load
// the mobile page though since the main page loads tweet contents via JS. // the mobile page though since the main page loads tweet contents via JS.
url = strings.Replace(url, "https://twitter.com/", "https://mobile.twitter.com/", 1) url = strings.Replace(url, "https://twitter.com/", "https://mobile.twitter.com/", 1)
res, err := http.Get(url) res, err := http.Get(url)
if err != nil { if err != nil {
return "", "", common.Address{}, err return "", "", "", common.Address{}, err
} }
defer res.Body.Close() defer res.Body.Close()
@ -708,24 +722,77 @@ func authTwitter(url string) (string, string, common.Address, error) {
parts = strings.Split(res.Request.URL.String(), "/") parts = strings.Split(res.Request.URL.String(), "/")
if len(parts) < 4 || parts[len(parts)-2] != "status" { if len(parts) < 4 || parts[len(parts)-2] != "status" {
//lint:ignore ST1005 This error is to be displayed in the browser //lint:ignore ST1005 This error is to be displayed in the browser
return "", "", common.Address{}, errors.New("Invalid Twitter status URL") return "", "", "", common.Address{}, errors.New("Invalid Twitter status URL")
} }
username := parts[len(parts)-3] username := parts[len(parts)-3]
body, err := ioutil.ReadAll(res.Body) body, err := ioutil.ReadAll(res.Body)
if err != nil { if err != nil {
return "", "", common.Address{}, err return "", "", "", common.Address{}, err
} }
address := common.HexToAddress(string(regexp.MustCompile("0x[0-9a-fA-F]{40}").Find(body))) address := common.HexToAddress(string(regexp.MustCompile("0x[0-9a-fA-F]{40}").Find(body)))
if address == (common.Address{}) { if address == (common.Address{}) {
//lint:ignore ST1005 This error is to be displayed in the browser //lint:ignore ST1005 This error is to be displayed in the browser
return "", "", common.Address{}, errors.New("No Ethereum address found to fund") return "", "", "", common.Address{}, errors.New("No Ethereum address found to fund")
} }
var avatar string var avatar string
if parts = regexp.MustCompile("src=\"([^\"]+twimg.com/profile_images[^\"]+)\"").FindStringSubmatch(string(body)); len(parts) == 2 { if parts = regexp.MustCompile("src=\"([^\"]+twimg.com/profile_images[^\"]+)\"").FindStringSubmatch(string(body)); len(parts) == 2 {
avatar = parts[1] avatar = parts[1]
} }
return username + "@twitter", avatar, address, nil return username + "@twitter", username, avatar, address, nil
}
// authTwitterWithToken tries to authenticate a faucet request using Twitter's API, returning
// the uniqueness identifier (user id/username), username, avatar URL and Ethereum address to fund on success.
func authTwitterWithToken(tweetID string, token string) (string, string, string, common.Address, error) {
// Strip any query parameters from the tweet id
sanitizedTweetID := strings.Split(tweetID, "?")[0]
// Ensure numeric tweetID
if !regexp.MustCompile("^[0-9]+$").MatchString(sanitizedTweetID) {
return "", "", "", common.Address{}, errors.New("Invalid Tweet URL")
}
// Query the tweet details from Twitter
url := fmt.Sprintf("https://api.twitter.com/2/tweets/%s?expansions=author_id&user.fields=profile_image_url", sanitizedTweetID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", "", "", common.Address{}, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", common.Address{}, err
}
defer res.Body.Close()
var result struct {
Data struct {
AuthorID string `json:"author_id"`
ID string `json:"id"`
Text string `json:"text"`
} `json:"data"`
Includes struct {
Users []struct {
ProfileImageURL string `json:"profile_image_url"`
Username string `json:"username"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"users"`
} `json:"includes"`
}
err = json.NewDecoder(res.Body).Decode(&result)
if err != nil {
return "", "", "", common.Address{}, err
}
address := common.HexToAddress(regexp.MustCompile("0x[0-9a-fA-F]{40}").FindString(result.Data.Text))
if address == (common.Address{}) {
//lint:ignore ST1005 This error is to be displayed in the browser
return "", "", "", common.Address{}, errors.New("No Ethereum address found to fund")
}
return result.Data.AuthorID + "@twitter", result.Includes.Users[0].Username, result.Includes.Users[0].ProfileImageURL, address, nil
} }
// authFacebook tries to authenticate a faucet request using Facebook posts, // authFacebook tries to authenticate a faucet request using Facebook posts,

@ -46,6 +46,7 @@ ENTRYPOINT [ \
"--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", "--faucet.tiers", "{{.FaucetTiers}}", \ "--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", "--faucet.tiers", "{{.FaucetTiers}}", \
"--account.json", "/account.json", "--account.pass", "/account.pass" \ "--account.json", "/account.json", "--account.pass", "/account.pass" \
{{if .CaptchaToken}}, "--captcha.token", "{{.CaptchaToken}}", "--captcha.secret", "{{.CaptchaSecret}}"{{end}}{{if .NoAuth}}, "--noauth"{{end}} \ {{if .CaptchaToken}}, "--captcha.token", "{{.CaptchaToken}}", "--captcha.secret", "{{.CaptchaSecret}}"{{end}}{{if .NoAuth}}, "--noauth"{{end}} \
{{if .TwitterToken}}, "--twitter.token", "{{.TwitterToken}}",
]` ]`
// faucetComposefile is the docker-compose.yml file required to deploy and maintain // faucetComposefile is the docker-compose.yml file required to deploy and maintain
@ -71,6 +72,7 @@ services:
- FAUCET_TIERS={{.FaucetTiers}} - FAUCET_TIERS={{.FaucetTiers}}
- CAPTCHA_TOKEN={{.CaptchaToken}} - CAPTCHA_TOKEN={{.CaptchaToken}}
- CAPTCHA_SECRET={{.CaptchaSecret}} - CAPTCHA_SECRET={{.CaptchaSecret}}
- TWITTER_TOKEN={{.TwitterToken}}
- NO_AUTH={{.NoAuth}}{{if .VHost}} - NO_AUTH={{.NoAuth}}{{if .VHost}}
- VIRTUAL_HOST={{.VHost}} - VIRTUAL_HOST={{.VHost}}
- VIRTUAL_PORT=8080{{end}} - VIRTUAL_PORT=8080{{end}}
@ -103,6 +105,7 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
"FaucetMinutes": config.minutes, "FaucetMinutes": config.minutes,
"FaucetTiers": config.tiers, "FaucetTiers": config.tiers,
"NoAuth": config.noauth, "NoAuth": config.noauth,
"TwitterToken": config.twitterToken,
}) })
files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes() files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes()
@ -120,6 +123,7 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
"FaucetMinutes": config.minutes, "FaucetMinutes": config.minutes,
"FaucetTiers": config.tiers, "FaucetTiers": config.tiers,
"NoAuth": config.noauth, "NoAuth": config.noauth,
"TwitterToken": config.twitterToken,
}) })
files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes() files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes()
@ -152,6 +156,7 @@ type faucetInfos struct {
noauth bool noauth bool
captchaToken string captchaToken string
captchaSecret string captchaSecret string
twitterToken string
} }
// Report converts the typed struct into a plain string->string map, containing // Report converts the typed struct into a plain string->string map, containing
@ -165,6 +170,7 @@ func (info *faucetInfos) Report() map[string]string {
"Funding cooldown (base tier)": fmt.Sprintf("%d mins", info.minutes), "Funding cooldown (base tier)": fmt.Sprintf("%d mins", info.minutes),
"Funding tiers": strconv.Itoa(info.tiers), "Funding tiers": strconv.Itoa(info.tiers),
"Captha protection": fmt.Sprintf("%v", info.captchaToken != ""), "Captha protection": fmt.Sprintf("%v", info.captchaToken != ""),
"Using Twitter API": fmt.Sprintf("%v", info.twitterToken != ""),
"Ethstats username": info.node.ethstats, "Ethstats username": info.node.ethstats,
} }
if info.noauth { if info.noauth {
@ -243,5 +249,6 @@ func checkFaucet(client *sshClient, network string) (*faucetInfos, error) {
captchaToken: infos.envvars["CAPTCHA_TOKEN"], captchaToken: infos.envvars["CAPTCHA_TOKEN"],
captchaSecret: infos.envvars["CAPTCHA_SECRET"], captchaSecret: infos.envvars["CAPTCHA_SECRET"],
noauth: infos.envvars["NO_AUTH"] == "true", noauth: infos.envvars["NO_AUTH"] == "true",
twitterToken: infos.envvars["TWITTER_TOKEN"],
}, nil }, nil
} }

@ -102,6 +102,29 @@ func (w *wizard) deployFaucet() {
infos.captchaSecret = w.readPassword() infos.captchaSecret = w.readPassword()
} }
} }
// Accessing the twitter api requires a bearer token, request it
if infos.twitterToken != "" {
fmt.Println()
fmt.Println("Reuse previous twitter API Bearer token (y/n)? (default = yes)")
if !w.readDefaultYesNo(true) {
infos.twitterToken = ""
}
}
if infos.twitterToken == "" {
// No previous twitter token (or old one discarded)
fmt.Println()
fmt.Println("Enable twitter API (y/n)? (default = no)")
if !w.readDefaultYesNo(false) {
log.Warn("The faucet will fallback to using direct calls")
} else {
// Twitter api explicitly requested, read the bearer token
fmt.Println()
fmt.Printf("What is the twitter API Bearer token?\n")
infos.twitterToken = w.readString()
}
}
// Figure out where the user wants to store the persistent data // Figure out where the user wants to store the persistent data
fmt.Println() fmt.Println()
if infos.node.datadir == "" { if infos.node.datadir == "" {