diff --git a/contribs/gnofaucet/cooldown.go b/contribs/gnofaucet/cooldown.go new file mode 100644 index 00000000000..bd5cf739718 --- /dev/null +++ b/contribs/gnofaucet/cooldown.go @@ -0,0 +1,36 @@ +package main + +import ( + "sync" + "time" +) + +// CooldownLimiter is a Limiter using an in-memory map +type CooldownLimiter struct { + cooldowns map[string]time.Time + mu sync.Mutex + cooldownTime time.Duration +} + +// NewCooldownLimiter initializes a Cooldown Limiter with a given duration +func NewCooldownLimiter(cooldown time.Duration) *CooldownLimiter { + return &CooldownLimiter{ + cooldowns: make(map[string]time.Time), + cooldownTime: cooldown, + } +} + +// CheckCooldown checks if a user is eligible for a reward claim +func (rl *CooldownLimiter) CheckCooldown(ghLogin string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + if lastClaim, found := rl.cooldowns[ghLogin]; found { + if time.Since(lastClaim) < rl.cooldownTime { + return false // Deny claim if within cooldown period + } + } + + rl.cooldowns[ghLogin] = time.Now() + return true +} diff --git a/contribs/gnofaucet/gh.go b/contribs/gnofaucet/gh.go new file mode 100644 index 00000000000..fbe5aca3fa0 --- /dev/null +++ b/contribs/gnofaucet/gh.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/go-github/v64/github" +) + +func getGithubMiddleware(clientID, secret string, cooldown time.Duration) func(next http.Handler) http.Handler { + coolDownLimiter := NewCooldownLimiter(cooldown) + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // Check if the captcha is enabled + if secret == "" || clientID == "" { + // Continue with serving the faucet request + next.ServeHTTP(w, r) + + return + } + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + + res, err := exchangeCodeForToken(secret, clientID, code) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + client := github.NewClient(http.DefaultClient).WithAuthToken(res.AccessToken) + user, _, err := client.Users.Get(r.Context(), "") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Just check if given account have asked for faucet before the cooldown period + if !coolDownLimiter.CheckCooldown(user.GetLogin()) { + http.Error(w, "user is on cooldown", http.StatusTooManyRequests) + return + } + + // Possibility to have more conditions like accountAge, commits, pullRequest etc + + next.ServeHTTP(w, r) + }, + ) + } +} + +type GitHubTokenResponse struct { + AccessToken string `json:"access_token"` +} + +func exchangeCodeForToken(secret, clientID, code string) (*GitHubTokenResponse, error) { + url := "https://github.com/login/oauth/access_token" + body := fmt.Sprintf("client_id=%s&client_secret=%s&code=%s", clientID, secret, code) + req, err := http.NewRequest("POST", url, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var tokenResponse GitHubTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return nil, err + } + + if tokenResponse.AccessToken == "" { + return nil, fmt.Errorf("unable to exchange code for token") + } + + return &tokenResponse, nil +} diff --git a/contribs/gnofaucet/go.mod b/contribs/gnofaucet/go.mod index 0a862162331..5f909f8bbc0 100644 --- a/contribs/gnofaucet/go.mod +++ b/contribs/gnofaucet/go.mod @@ -5,6 +5,7 @@ go 1.23.6 require ( github.com/gnolang/faucet v0.3.2 github.com/gnolang/gno v0.1.0-nightly.20240627 + github.com/google/go-github/v64 v64.0.0 github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.27.0 golang.org/x/time v0.5.0 @@ -21,6 +22,7 @@ require ( github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect diff --git a/contribs/gnofaucet/go.sum b/contribs/gnofaucet/go.sum index e6743b75960..a3a98dfb2f4 100644 --- a/contribs/gnofaucet/go.sum +++ b/contribs/gnofaucet/go.sum @@ -70,8 +70,13 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg= +github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/contribs/gnofaucet/serve.go b/contribs/gnofaucet/serve.go index 837e620c5aa..e3127784437 100644 --- a/contribs/gnofaucet/serve.go +++ b/contribs/gnofaucet/serve.go @@ -57,8 +57,10 @@ type serveCfg struct { remote string - captchaSecret string - isBehindProxy bool + captchaSecret string + ghClientID string + ghClientSecret string + isBehindProxy bool } func newServeCmd() *commands.Command { @@ -127,6 +129,20 @@ func (c *serveCfg) RegisterFlags(fs *flag.FlagSet) { "recaptcha secret key (if empty, captcha are disabled)", ) + fs.StringVar( + &c.ghClientSecret, + "github-client-secret", + "", + "github client secret for oauth authentication (if empty, middleware is disabled)", + ) + + fs.StringVar( + &c.ghClientID, + "github-client-id", + "", + "github client id for oauth authentication", + ) + fs.BoolVar( &c.isBehindProxy, "is-behind-proxy", @@ -195,6 +211,7 @@ func execServe(ctx context.Context, cfg *serveCfg, io commands.IO) error { middlewares := []faucet.Middleware{ getIPMiddleware(cfg.isBehindProxy, st), getCaptchaMiddleware(cfg.captchaSecret), + getGithubMiddleware(cfg.ghClientID, cfg.ghClientSecret, 1*time.Hour), } // Create a new faucet with