diff --git a/contribs/gnofaucet/coins.go b/contribs/gnofaucet/coins.go new file mode 100644 index 00000000000..6169db120e7 --- /dev/null +++ b/contribs/gnofaucet/coins.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + + tm2Client "github.com/gnolang/faucet/client/http" + "github.com/gnolang/gno/tm2/pkg/crypto" +) + +func getAccountBalanceMiddleware(tm2Client *tm2Client.Client, maxBalance int64) func(next http.Handler) http.Handler { + type request struct { + To string `json:"to"` + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + var data request + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = json.Unmarshal(body, &data) + r.Body = io.NopCloser(bytes.NewBuffer(body)) + balance, err := checkAccountBalance(tm2Client, data.To) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if balance >= maxBalance { + http.Error(w, "accounts is already topped up", http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }, + ) + } +} + +var checkAccountBalance = func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) { + address, err := crypto.AddressFromString(walletAddress) + if err != nil { + return 0, err + } + acc, err := tm2Client.GetAccount(address) + if err != nil { + return 0, err + } + return acc.GetCoins().AmountOf("ugnot"), nil +} diff --git a/contribs/gnofaucet/coins_test.go b/contribs/gnofaucet/coins_test.go new file mode 100644 index 00000000000..7efb0a10def --- /dev/null +++ b/contribs/gnofaucet/coins_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + tm2Client "github.com/gnolang/faucet/client/http" + "github.com/stretchr/testify/assert" +) + +func mockedCheckAccountBalance(amount int64, err error) func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) { + return func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) { + return amount, err + } +} + +func TestGetAccountBalanceMiddleware(t *testing.T) { + maxBalance := int64(1000) + + tests := []struct { + name string + requestBody map[string]string + expectedStatus int + expectedBody string + checkBalanceFunc func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) + }{ + { + name: "Valid address with low balance (should pass)", + requestBody: map[string]string{"to": "valid_address_low_balance"}, + expectedStatus: http.StatusOK, + expectedBody: "next handler reached", + checkBalanceFunc: mockedCheckAccountBalance(500, nil), + }, + { + name: "Valid address with high balance (should fail)", + requestBody: map[string]string{"To": "valid_address_high_balance"}, + expectedStatus: http.StatusBadRequest, + expectedBody: "accounts is already topped up", + checkBalanceFunc: mockedCheckAccountBalance(2*maxBalance, nil), + }, + { + name: "Invalid address (should fail)", + requestBody: map[string]string{"To": "invalid_address"}, + expectedStatus: http.StatusBadRequest, + expectedBody: "account not found", + checkBalanceFunc: mockedCheckAccountBalance(2*maxBalance, errors.New("account not found")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkAccountBalance = tt.checkBalanceFunc + // Convert request body to JSON + reqBody, _ := json.Marshal(tt.requestBody) + + // Create request + req := httptest.NewRequest(http.MethodPost, "/claim", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + // Create ResponseRecorder + rr := httptest.NewRecorder() + + // Mock next handler + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("next handler reached")) + }) + + // Apply middleware + handler := getAccountBalanceMiddleware(nil, maxBalance)(nextHandler) + handler.ServeHTTP(rr, req) + + // Check response + assert.Equal(t, tt.expectedStatus, rr.Code) + assert.Contains(t, rr.Body.String(), tt.expectedBody) + }) + } +} diff --git a/contribs/gnofaucet/cooldown.go b/contribs/gnofaucet/cooldown.go new file mode 100644 index 00000000000..286ab5de219 --- /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 key has done some action before the cooldown period has passed +func (rl *CooldownLimiter) CheckCooldown(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + if lastClaim, found := rl.cooldowns[key]; found { + if time.Since(lastClaim) < rl.cooldownTime { + return false // Deny claim if within cooldown period + } + } + + rl.cooldowns[key] = time.Now() + return true +} diff --git a/contribs/gnofaucet/cooldown_test.go b/contribs/gnofaucet/cooldown_test.go new file mode 100644 index 00000000000..10aebb2ee1b --- /dev/null +++ b/contribs/gnofaucet/cooldown_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCooldownLimiter(t *testing.T) { + cooldownDuration := time.Second + limiter := NewCooldownLimiter(cooldownDuration) + user := "testUser" + + // First check should be allowed + if !limiter.CheckCooldown(user) { + t.Errorf("Expected first CheckCooldown to return true, but got false") + } + + // Second check immediately should be denied + if limiter.CheckCooldown(user) { + t.Errorf("Expected second CheckCooldown to return false, but got true") + } + + require.Eventually(t, func() bool { + return limiter.CheckCooldown(user) + }, 2*cooldownDuration, 10*time.Millisecond, "Expected CheckCooldown to return true after cooldown period") +} diff --git a/contribs/gnofaucet/gh.go b/contribs/gnofaucet/gh.go new file mode 100644 index 00000000000..2dd3d6a8e16 --- /dev/null +++ b/contribs/gnofaucet/gh.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "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) { + // github Oauth flow 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 + } + + user, err := exchangeCodeForUser(r.Context(), secret, clientID, code) + 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"` +} + +var exchangeCodeForUser = func(ctx context.Context, secret, clientID, code string) (*github.User, 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") + } + + ghClient := github.NewClient(http.DefaultClient).WithAuthToken(tokenResponse.AccessToken) + user, _, err := ghClient.Users.Get(ctx, "") + return user, err +} diff --git a/contribs/gnofaucet/gh_test.go b/contribs/gnofaucet/gh_test.go new file mode 100644 index 00000000000..c131060917e --- /dev/null +++ b/contribs/gnofaucet/gh_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/go-github/v64/github" +) + +// Mock function for exchangeCodeForToken +func mockExchangeCodeForToken(ctx context.Context, secret, clientID, code string) (*github.User, error) { + login := "mock_login" + if code == "valid" { + fmt.Println("mockExchangeCodeForToken: valid") + return &github.User{Login: &login}, nil + } + return nil, errors.New("invalid code") +} + +// Mock function for GitHub client +func mockGetUser(token string) (*github.User, error) { + if token == "mock_token" { + return &github.User{Login: github.String("testUser")}, nil + } + return nil, errors.New("invalid token") +} + +func TestGitHubMiddleware(t *testing.T) { + cooldown := 2 * time.Minute + exchangeCodeForUser = mockExchangeCodeForToken + t.Run("Midleware without credentials", func(t *testing.T) { + middleware := getGithubMiddleware("", "", cooldown) + // Test missing clientID and secret, middleware does nothing + req := httptest.NewRequest("GET", "http://localhost", nil) + rec := httptest.NewRecorder() + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", rec.Code) + } + }) + t.Run("request without code", func(t *testing.T) { + middleware := getGithubMiddleware("mockClientID", "mockSecret", cooldown) + req := httptest.NewRequest("GET", "http://localhost?code=", nil) + rec := httptest.NewRecorder() + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status BadRequest, got %d", rec.Code) + } + }) + + t.Run("request invalid code", func(t *testing.T) { + middleware := getGithubMiddleware("mockClientID", "mockSecret", cooldown) + req := httptest.NewRequest("GET", "http://localhost?code=invalid", nil) + rec := httptest.NewRecorder() + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status BadRequest, got %d", rec.Code) + } + }) + + t.Run("OK", func(t *testing.T) { + middleware := getGithubMiddleware("mockClientID", "mockSecret", cooldown) + req := httptest.NewRequest("GET", "http://localhost?code=valid", nil) + rec := httptest.NewRecorder() + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", rec.Code) + } + }) + + t.Run("Cooldown active", func(t *testing.T) { + middleware := getGithubMiddleware("mockClientID", "mockSecret", cooldown) + req := httptest.NewRequest("GET", "http://localhost?code=valid", nil) + rec := httptest.NewRecorder() + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", rec.Code) + } + + req = httptest.NewRequest("GET", "http://localhost?code=valid", nil) + rec = httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Errorf("Expected status TooManyRequest, got %d", rec.Code) + } + }) +} 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..ff7cc33a982 100644 --- a/contribs/gnofaucet/serve.go +++ b/contribs/gnofaucet/serve.go @@ -57,8 +57,11 @@ type serveCfg struct { remote string - captchaSecret string - isBehindProxy bool + captchaSecret string + ghClientID string + maxBalance int64 + ghClientSecret string + isBehindProxy bool } func newServeCmd() *commands.Command { @@ -127,6 +130,27 @@ 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.Int64Var( + &c.maxBalance, + "max-balance", + 10000000, // 10 ugnot + "limit of tokens the user can possess to be eligible to claim the faucet", + ) + fs.BoolVar( &c.isBehindProxy, "is-behind-proxy", @@ -195,6 +219,8 @@ 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), + getAccountBalanceMiddleware(cli, cfg.maxBalance), } // Create a new faucet with