Skip to content

Commit 861adbe

Browse files
authored
Merge pull request #19 from sygmaprotocol/feat/control-access-per-token
feat: add multiple rate limited auth tokens
2 parents 98b23e9 + 6480e74 commit 861adbe

File tree

13 files changed

+298
-24
lines changed

13 files changed

+298
-24
lines changed

.gitignore

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,3 @@
66

77
.idea
88

9-
config.json
10-
config_*
11-
12-
prometheus

README.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,40 @@ Each JSON configuration file for the gateways can specify detailed settings for
107107
```
108108

109109
## Authentication
110-
Authentication can be enabled using the `--auth` flag. The auth token should be set through environment variables `GATEWAY_PASSWORD`.
111110

112-
Auth token needs to be the last entry in the RPC gateway URL. Example:
111+
Authentication can be enabled using the `--auth` flag. The authentication system uses a token-based approach with rate limiting.
113112

114-
`https://sample/rpc-gateway/sepolia/a1b2c3d4e5f7`
113+
### Token Configuration
115114

116-
### Running the Application
117-
To run the application with authentication:
115+
The token configuration should be provided through the `GATEWAY_TOKEN_MAP` environment variable. This variable should contain a JSON string representing a map of tokens to their corresponding information. Each token entry includes a name and the number of requests allowed per second.
118116

117+
Example of `GATEWAY_TOKEN_MAP`:
118+
119+
```json
120+
{
121+
"token1": {"name": "User1", "numOfRequestPerSec": 10},
122+
"token2": {"name": "User2", "numOfRequestPerSec": 20}
123+
}
119124
```
120-
DEBUG=true GATEWAY_PASSWORD=my_auth_token go run . --config config.json --auth
121-
```
125+
126+
### URL Format
127+
128+
When authentication is enabled, the auth token needs to be the last entry in the RPC gateway URL.
129+
130+
Example:
131+
132+
`https://sample/rpc-gateway/sepolia/token1`
133+
134+
In this example, `token1` is the authentication token that must match one of the tokens defined in the `GATEWAY_TOKEN_MAP`.
135+
136+
### Rate Limiting
137+
138+
Each token has its own rate limit, defined by the `numOfRequestPerSec` value in the token configuration. If a client exceeds this limit, they will receive a 429 (Too Many Requests) status code.
139+
140+
### Running the Application with Authentication
141+
142+
To run the application with authentication:
143+
144+
```bash
145+
export GATEWAY_TOKEN_MAP='{"token1":{"name":"User1","numOfRequestPerSec":10},"token2":{"name":"User2","numOfRequestPerSec":20}}'
146+
DEBUG=true go run . --config config.json --auth

config.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"metrics": {
3+
"port": 9090
4+
},
5+
"port": 4000,
6+
"gateways": [
7+
{
8+
"configFile": "/app/config_holesky.json",
9+
"name": "Holesky gateway"
10+
},
11+
{
12+
"configFile": "/app/config_sepolia.json",
13+
"name": "Sepolia gateway"
14+
}
15+
]
16+
}

config_holesky.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "Holesky",
3+
"proxy": {
4+
"path": "holesky",
5+
"upstreamTimeout": "1s"
6+
},
7+
"healthChecks": {
8+
"interval": "20s",
9+
"timeout": "1s",
10+
"failureThreshold": 2,
11+
"successThreshold": 1
12+
},
13+
"targets": [
14+
{
15+
"name": "ChainSafe",
16+
"connection": {
17+
"http": {
18+
"url": "https://lodestar-holeskyrpc.chainsafe.io/"
19+
}
20+
}
21+
},
22+
{
23+
"name": "Tenderly",
24+
"connection": {
25+
"http": {
26+
"url": "https://holesky.gateway.tenderly.co"
27+
}
28+
}
29+
}
30+
]
31+
}

config_sepolia.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "Sepolia",
3+
"proxy": {
4+
"path": "sepolia",
5+
"upstreamTimeout": "1s"
6+
},
7+
"healthChecks": {
8+
"interval": "20s",
9+
"timeout": "1s",
10+
"failureThreshold": 2,
11+
"successThreshold": 1
12+
},
13+
"targets": [
14+
{
15+
"name": "ChainSafe",
16+
"connection": {
17+
"http": {
18+
"url": "https://lodestar-sepoliarpc.chainsafe.io"
19+
}
20+
}
21+
},
22+
{
23+
"name": "Tenderly",
24+
"connection": {
25+
"http": {
26+
"url": "https://sepolia.gateway.tenderly.co"
27+
}
28+
}
29+
}
30+
]
31+
}

docker-compose.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
services:
2+
rpc-gateway:
3+
build:
4+
context: .
5+
dockerfile: Dockerfile
6+
ports:
7+
- 8080:4000 # Main port
8+
- 9090:9090 # Metrics port
9+
volumes:
10+
- ./config.json:/app/config.json:ro
11+
- ./config_sepolia.json:/app/config_sepolia.json:ro
12+
- ./config_holesky.json:/app/config_holesky.json:ro
13+
environment:
14+
- GATEWAY_TOKEN_MAP={"token1":{"name":"token1","numOfRequestPerSec":10},"token2":{"name":"token2","numOfRequestPerSec":20}}
15+
user: nobody
16+
entrypoint: ["/app/rpc-gateway", "--config", "/app/config.json", "--auth"]
17+
networks:
18+
- app-network
19+
20+
prometheus:
21+
image: prom/prometheus:v2.44.0
22+
volumes:
23+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
24+
command:
25+
- '--config.file=/etc/prometheus/prometheus.yml'
26+
ports:
27+
- "9091:9090" # Changed to 9091 on the host
28+
networks:
29+
- app-network
30+
31+
grafana:
32+
image: grafana/grafana:latest
33+
ports:
34+
- 3000:3000
35+
volumes:
36+
- grafana-storage:/var/lib/grafana
37+
environment:
38+
- GF_SECURITY_ADMIN_PASSWORD=admin
39+
depends_on:
40+
- rpc-gateway
41+
networks:
42+
- app-network
43+
44+
volumes:
45+
grafana-storage:
46+
47+
networks:
48+
app-network:
49+
driver: bridge

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ require (
4444
golang.org/x/mod v0.15.0 // indirect
4545
golang.org/x/net v0.21.0 // indirect
4646
golang.org/x/sys v0.17.0 // indirect
47+
golang.org/x/time v0.7.0
4748
golang.org/x/tools v0.18.0 // indirect
4849
google.golang.org/protobuf v1.32.0 // indirect
4950
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
103103
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104104
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
105105
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
106+
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
107+
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
106108
golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ=
107109
golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg=
108110
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=

internal/auth/auth.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,70 @@
11
package auth
22

33
import (
4+
"context"
5+
"fmt"
46
"net/http"
57
"strings"
8+
9+
"golang.org/x/time/rate"
610
)
711

8-
func URLTokenAuth(token string) func(next http.Handler) http.Handler {
12+
type TokenInfo struct {
13+
Name string `json:"name"`
14+
NumOfRequestPerSec int `json:"numOfRequestPerSec"`
15+
}
16+
17+
// ContextKeyType custom type for the context key.
18+
type ContextKeyType string
19+
20+
const TokenInfoKey ContextKeyType = "tokeninfo"
21+
22+
func URLTokenAuth(tokenToName map[string]TokenInfo) func(next http.Handler) http.Handler {
23+
limiters := make(map[string]*rate.Limiter)
24+
for token, info := range tokenToName {
25+
limiters[token] = rate.NewLimiter(rate.Limit(info.NumOfRequestPerSec), info.NumOfRequestPerSec)
26+
fmt.Printf("Configured limiter for %s, allowed %d requests per second\n",
27+
info.Name, info.NumOfRequestPerSec,
28+
)
29+
}
30+
931
return func(next http.Handler) http.Handler {
1032
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1133
pathParts := strings.Split(r.URL.Path, "/")
12-
if len(pathParts) < 2 || pathParts[len(pathParts)-1] != token {
34+
if len(pathParts) < 2 {
35+
w.WriteHeader(http.StatusUnauthorized)
36+
37+
return
38+
}
39+
40+
token := pathParts[len(pathParts)-1]
41+
tInfo, validToken := tokenToName[token]
42+
if !validToken {
1343
w.WriteHeader(http.StatusUnauthorized)
1444

1545
return
1646
}
17-
// Remove the token part from the path to forward the request to the next handler
47+
48+
limiter, exists := limiters[token]
49+
if !exists {
50+
w.WriteHeader(http.StatusInternalServerError)
51+
52+
return
53+
}
54+
55+
if !limiter.Allow() {
56+
w.WriteHeader(http.StatusTooManyRequests)
57+
58+
return
59+
}
60+
61+
// Remove the token part from the path
1862
r.URL.Path = strings.Join(pathParts[:len(pathParts)-1], "/")
63+
64+
// Add the user's name to the request context
65+
ctx := context.WithValue(r.Context(), TokenInfoKey, tInfo)
66+
r = r.WithContext(ctx)
67+
1968
next.ServeHTTP(w, r)
2069
})
2170
}

internal/auth/auth_test.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7+
"time"
78
)
89

910
func TestURLTokenAuth(t *testing.T) {
1011
validToken := "valid_token"
11-
middleware := URLTokenAuth(validToken)
12+
tokenInfo := TokenInfo{
13+
Name: "Test User",
14+
NumOfRequestPerSec: 1, // Changed from 60 per minute to 1 per second
15+
}
16+
tokenMap := map[string]TokenInfo{validToken: tokenInfo}
1217

1318
tests := []struct {
1419
name string
@@ -21,7 +26,7 @@ func TestURLTokenAuth(t *testing.T) {
2126
expectedStatus: http.StatusOK,
2227
},
2328
{
24-
name: "Valid token",
29+
name: "Valid token with long path",
2530
url: "/some/really/long/path/valid_token",
2631
expectedStatus: http.StatusOK,
2732
},
@@ -39,6 +44,7 @@ func TestURLTokenAuth(t *testing.T) {
3944

4045
for _, tt := range tests {
4146
t.Run(tt.name, func(t *testing.T) {
47+
middleware := URLTokenAuth(tokenMap)
4248
req, err := http.NewRequest("GET", tt.url, nil)
4349
if err != nil {
4450
t.Fatalf("could not create request: %v", err)
@@ -57,3 +63,50 @@ func TestURLTokenAuth(t *testing.T) {
5763
})
5864
}
5965
}
66+
67+
func TestURLTokenAuthRateLimit(t *testing.T) {
68+
validToken := "valid_token"
69+
tokenInfo := TokenInfo{
70+
Name: "Test User",
71+
NumOfRequestPerSec: 5, // Changed from 60 per minute to 1 per second
72+
}
73+
tokenMap := map[string]TokenInfo{validToken: tokenInfo}
74+
middleware := URLTokenAuth(tokenMap)
75+
76+
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
w.WriteHeader(http.StatusOK)
78+
}))
79+
80+
url := "/some/path/valid_token"
81+
82+
// Make requests up to the limit
83+
for i := 0; i < tokenInfo.NumOfRequestPerSec; i++ {
84+
req, _ := http.NewRequest("GET", url, nil)
85+
rr := httptest.NewRecorder()
86+
handler.ServeHTTP(rr, req)
87+
if rr.Code != http.StatusOK {
88+
t.Fatalf("Expected OK for request %d, got %d", i, rr.Code)
89+
}
90+
}
91+
92+
// This request should exceed the rate limit
93+
req, _ := http.NewRequest("GET", url, nil)
94+
rr := httptest.NewRecorder()
95+
handler.ServeHTTP(rr, req)
96+
97+
if rr.Code != http.StatusTooManyRequests {
98+
t.Errorf("Expected status %v for rate limit exceeded; got %v", http.StatusTooManyRequests, rr.Code)
99+
}
100+
101+
// Wait for a second to allow the rate limiter to reset
102+
time.Sleep(time.Second)
103+
104+
// This request should now succeed
105+
req, _ = http.NewRequest("GET", url, nil)
106+
rr = httptest.NewRecorder()
107+
handler.ServeHTTP(rr, req)
108+
109+
if rr.Code != http.StatusOK {
110+
t.Errorf("Expected status %v after rate limit reset; got %v", http.StatusOK, rr.Code)
111+
}
112+
}

0 commit comments

Comments
 (0)