Skip to content

feat: update gnofaucet requests / responses #4303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion contribs/gnofaucet/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"flag"
"fmt"
"net/http"

"github.com/gnolang/faucet"
"github.com/gnolang/gno/tm2/pkg/commands"
)

Expand Down Expand Up @@ -47,5 +49,24 @@ func execCaptcha(ctx context.Context, cfg *captchaCfg, io commands.IO) error {
return errCaptchaMissing
}

return serveFaucet(ctx, cfg.rootCfg, io, getCaptchaMiddleware(cfg.captchaSecret))
// Start the IP throttler
st := newIPThrottler(defaultRateLimitInterval, defaultCleanTimeout)
st.start(ctx)

// Prepare the middlewares
httpMiddlewares := []func(http.Handler) http.Handler{
ipMiddleware(cfg.rootCfg.isBehindProxy, st),
}

rpcMiddlewares := []faucet.Middleware{
captchaMiddleware(cfg.captchaSecret),
}

return serveFaucet(
ctx,
cfg.rootCfg,
io,
faucet.WithHTTPMiddlewares(httpMiddlewares),
faucet.WithMiddlewares(rpcMiddlewares),
)
}
18 changes: 9 additions & 9 deletions contribs/gnofaucet/cooldown.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import (
"github.com/redis/go-redis/v9"
)

// CooldownLimiter limits a specific user to one claim per cooldown period
// redisLimiter limits a specific user to one claim per cooldown period
// this limiter keeps track of which keys are on cooldown using a badger database (written to a local file)
type CooldownLimiter struct {
type redisLimiter struct {
redis *redis.Client
cooldownTime time.Duration
maxlifeTimeAmount *int64
}

// NewCooldownLimiter initializes a Cooldown Limiter with a given duration
func NewCooldownLimiter(cooldown time.Duration, redis *redis.Client, maxlifeTimeAmount int64) *CooldownLimiter {
limiter := &CooldownLimiter{
// newRedisLimiter initializes a Cooldown Limiter with a given duration
func newRedisLimiter(cooldown time.Duration, redis *redis.Client, maxlifeTimeAmount int64) *redisLimiter {
limiter := &redisLimiter{
redis: redis,
cooldownTime: cooldown,
}
Expand All @@ -31,11 +31,11 @@ func NewCooldownLimiter(cooldown time.Duration, redis *redis.Client, maxlifeTime
return limiter
}

// CheckCooldown checks if a key can make a claim or if it is still within the cooldown period
// checkCooldown checks if a key can make a claim or if it is still within the cooldown period
// also checks that the user will not exceed the max lifetime allowed amount
// Returns true if the key is not on cooldown, and marks the key as on cooldown
// Returns false if the key is on cooldown or if an error occurs
func (rl *CooldownLimiter) CheckCooldown(ctx context.Context, key string, amountClaimed int64) (bool, error) {
func (rl *redisLimiter) checkCooldown(ctx context.Context, key string, amountClaimed int64) (bool, error) {
claimData, err := rl.getClaimsData(ctx, key)
if err != nil {
return false, fmt.Errorf("unable to check if key is on cooldown, %w", err)
Expand All @@ -52,7 +52,7 @@ func (rl *CooldownLimiter) CheckCooldown(ctx context.Context, key string, amount
return true, rl.declareClaimedValue(ctx, key, amountClaimed, claimData)
}

func (rl *CooldownLimiter) getClaimsData(ctx context.Context, key string) (*claimData, error) {
func (rl *redisLimiter) getClaimsData(ctx context.Context, key string) (*claimData, error) {
storedData, err := rl.redis.Get(ctx, key).Result()
if err != nil {
// Here we return an empty claimData because is the first time the user is making a claim
Expand All @@ -69,7 +69,7 @@ func (rl *CooldownLimiter) getClaimsData(ctx context.Context, key string) (*clai
return claimData, err
}

func (rl *CooldownLimiter) declareClaimedValue(ctx context.Context, key string, amountClaimed int64, currentData *claimData) error {
func (rl *redisLimiter) declareClaimedValue(ctx context.Context, key string, amountClaimed int64, currentData *claimData) error {
currentData.LastClaimed = time.Now()
currentData.TotalClaimed += amountClaimed

Expand Down
14 changes: 7 additions & 7 deletions contribs/gnofaucet/cooldown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,27 @@ func TestCooldownLimiter(t *testing.T) {
})

cooldownDuration := time.Second
limiter := NewCooldownLimiter(cooldownDuration, rdb, 0)
limiter := newRedisLimiter(cooldownDuration, rdb, 0)
ctx := context.Background()
user := "testUser"

// First check should be allowed
allowed, err := limiter.CheckCooldown(ctx, user, tenGnots)
allowed, err := limiter.checkCooldown(ctx, user, tenGnots)
require.NoError(t, err)

if !allowed {
t.Errorf("Expected first CheckCooldown to return true, but got false")
t.Errorf("Expected first checkCooldown to return true, but got false")
}

allowed, err = limiter.CheckCooldown(ctx, user, tenGnots)
allowed, err = limiter.checkCooldown(ctx, user, tenGnots)
require.NoError(t, err)
// Second check immediately should be denied
if allowed {
t.Errorf("Expected second CheckCooldown to return false, but got true")
t.Errorf("Expected second checkCooldown to return false, but got true")
}

require.Eventually(t, func() bool {
allowed, err := limiter.CheckCooldown(ctx, user, tenGnots)
allowed, err := limiter.checkCooldown(ctx, user, tenGnots)
return err == nil && !allowed
}, 2*cooldownDuration, 10*time.Millisecond, "Expected CheckCooldown to return true after cooldown period")
}, 2*cooldownDuration, 10*time.Millisecond, "Expected checkCooldown to return true after cooldown period")
}
138 changes: 86 additions & 52 deletions contribs/gnofaucet/gh.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"

"github.com/gnolang/faucet"
"github.com/gnolang/faucet/spec"
"github.com/gnolang/gno/tm2/pkg/std"
"github.com/google/go-github/v64/github"
)

// getGithubMiddleware sets up authentication middleware for GitHub OAuth.
// ghUsernameKey is the context key for storing the GH username between
// http and RPC GitHub middleware handlers
const ghUsernameKey = "gh-username"

// gitHubUsernameMiddleware sets up authentication middleware for GitHub OAuth.
// If clientID and secret are empty, the middleware does nothing.
//
// Parameters:
// - clientID: The OAuth client ID issued by GitHub when registering the application.
// - secret: The OAuth client secret used to securely authenticate API requests.
// - cooldown: A cooldown duration to prevent several claims from the same user.
//
// GitHub OAuth applications require a client ID and secret to authenticate users securely.
// These credentials are obtained when registering an application on GitHub at:
// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authenticating-to-the-rest-api-with-an-oauth-app#registering-your-app
func getGithubMiddleware(clientID, secret string, coolDownLimiter *CooldownLimiter) func(next http.Handler) http.Handler {
func gitHubUsernameMiddleware(clientID, secret string, exchangeFn ghExchangeFn) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")

// Extracts the authorization code returned by the GitHub OAuth flow.
//
// When a user successfully authenticates via GitHub OAuth, GitHub redirects them
Expand All @@ -44,66 +49,92 @@
return
}

user, err := exchangeCodeForUser(r.Context(), secret, clientID, code)
user, err := exchangeFn(
r.Context(),
fmt.Sprintf("client_id=%s&client_secret=%s&code=%s", clientID, secret, code),
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)

return
}

claimAmount, err := getClaimAmount(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

// Just check if given account have asked for faucet before the cooldown period
allowedToClaim, err := coolDownLimiter.CheckCooldown(r.Context(), user.GetLogin(), claimAmount)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

if !allowedToClaim {
http.Error(w, "user is on cooldown", http.StatusTooManyRequests)
return
}
// Save the username in the context
updatedCtx := context.WithValue(r.Context(), ghUsernameKey, user.GetLogin())

// Possibility to have more conditions like accountAge, commits, pullRequest, etc.
next.ServeHTTP(w, r)
next.ServeHTTP(w, r.WithContext(updatedCtx))
},
)
}
}

type request struct {
Amount string `json:"amount"`
type cooldownLimiter interface {
checkCooldown(context.Context, string, int64) (bool, error)
}

func getClaimAmount(r *http.Request) (int64, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return 0, err
}

var data request
err = json.Unmarshal(body, &data)
if err != nil {
return 0, err
// gitHubClaimMiddleware is the GitHub claim validation middleware, based on the provided username
func gitHubClaimMiddleware(coolDownLimiter cooldownLimiter) faucet.Middleware {
return func(next faucet.HandlerFunc) faucet.HandlerFunc {
return func(ctx context.Context, req *spec.BaseJSONRequest) *spec.BaseJSONResponse {
// Grab the username from the context
username, ok := ctx.Value(ghUsernameKey).(string)
if !ok {
return spec.NewJSONResponse(
req.ID,
nil,
spec.NewJSONError("invalid username value", spec.InvalidRequestErrorCode),
)
}

// Make sure the method is "drip"
if req.Method != faucet.DefaultDripMethod {
return spec.NewJSONResponse(
req.ID,
nil,
spec.NewJSONError("invalid method requested", spec.InvalidRequestErrorCode),
)
}

// Grab the claim amount
if len(req.Params) < 2 {
return spec.NewJSONResponse(
req.ID,
nil,
spec.NewJSONError("amount not provided", spec.InvalidParamsErrorCode),
)
}

claimAmount, err := std.ParseCoin(req.Params[1].(string))
if err != nil {
return spec.NewJSONResponse(
req.ID,
nil,
spec.NewJSONError("invalid amount", spec.InvalidParamsErrorCode),
)
}

// Just check if given account have asked for faucet before the cooldown period
allowedToClaim, err := coolDownLimiter.checkCooldown(ctx, username, claimAmount.Amount)
if err != nil {
return spec.NewJSONResponse(
req.ID,
nil,
spec.NewJSONError("unable to check cooldown", spec.ServerErrorCode),
)
}

if !allowedToClaim {
return spec.NewJSONResponse(
req.ID,
nil,
spec.NewJSONError("user is on cooldown", spec.ServerErrorCode),
)
}

return next(ctx, req)
}
}
r.Body = io.NopCloser(bytes.NewBuffer(body))

// amount sent is a string, so we need to convert it to int64
// Ex: "1000000ugnot" -> 1000000
// Regex to extract leading digits
re := regexp.MustCompile(`^\d+`)
numericPart := re.FindString(data.Amount)

value, err := strconv.ParseInt(numericPart, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid claim amount, %w", err)
}
return value, nil
}

// ghTokenResponse is the GitHub OAuth response
Expand All @@ -124,14 +155,17 @@
//nolint:gosec
const githubTokenExchangeURL = "https://github.com/login/oauth/access_token"

var exchangeCodeForUser = func(ctx context.Context, secret, clientID, code string) (*github.User, error) {
type ghExchangeFn func(context.Context, string) (*github.User, error)

func defaultGHExchange(ctx context.Context, body string) (*github.User, error) {

Check warning on line 160 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L160

Added line #L160 was not covered by tests
client := new(http.Client)

req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
githubTokenExchangeURL,
strings.NewReader(fmt.Sprintf("client_id=%s&client_secret=%s&code=%s", clientID, secret, code)))
strings.NewReader(body),
)

Check warning on line 168 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L167-L168

Added lines #L167 - L168 were not covered by tests
if err != nil {
return nil, fmt.Errorf("unable to create HTTP request: %w", err)
}
Expand Down
Loading
Loading