Skip to content

Commit 915dc8d

Browse files
committed
adds api key support
1 parent 3b37be8 commit 915dc8d

5 files changed

Lines changed: 381 additions & 19 deletions

File tree

db_migrations.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,7 +2722,7 @@ var migrations = []any{
27222722
block varchar(128) NOT NULL,
27232723
change_id bigint GENERATED ALWAYS AS IDENTITY,
27242724
proxy_id uuid NOT NULL,
2725-
2725+
27262726
PRIMARY KEY (proxy_host, block, change_id)
27272727
)
27282728
`),
@@ -2731,7 +2731,7 @@ var migrations = []any{
27312731
CREATE TABLE proxy_client_ipv4 (
27322732
sequence_id bigint NOT NULL,
27332733
client_ipv4 bigint NOT NULL,
2734-
2734+
27352735
PRIMARY KEY (sequence_id, client_ipv4)
27362736
)
27372737
`),
@@ -2759,4 +2759,20 @@ var migrations = []any{
27592759
newSqlMigration(`
27602760
CREATE INDEX IF NOT EXISTS transfer_contract_payer_network_id ON transfer_contract (payer_network_id, open, contract_id)
27612761
`),
2762+
2763+
newSqlMigration(`
2764+
CREATE TABLE account_api_key (
2765+
api_key_id uuid NOT NULL DEFAULT gen_random_uuid(),
2766+
network_id uuid NOT NULL,
2767+
api_key varchar(128) NOT NULL,
2768+
name varchar(128) NOT NULL DEFAULT '',
2769+
create_time timestamp NOT NULL DEFAULT now(),
2770+
2771+
PRIMARY KEY (api_key_id),
2772+
UNIQUE (api_key)
2773+
)
2774+
`),
2775+
newSqlMigration(`
2776+
CREATE INDEX account_api_key_network_id ON account_api_key (network_id, api_key_id)
2777+
`),
27622778
}

model/account_api_key_model.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"fmt"
8+
"time"
9+
10+
"github.com/urnetwork/server"
11+
"github.com/urnetwork/server/session"
12+
)
13+
14+
type CreateApiKeyArgs struct {
15+
Name string `json:"name"`
16+
}
17+
18+
type CreateApiKeyError struct {
19+
Message string `json:"message"`
20+
}
21+
22+
type CreateApiKeyResult struct {
23+
Id server.Id `json:"id,omitempty"`
24+
ApiKey string `json:"api_key,omitempty"`
25+
Error *CreateApiKeyError `json:"error,omitempty"`
26+
}
27+
28+
func CreateApiKey(createApiKey *CreateApiKeyArgs, session *session.ClientSession) (result *CreateApiKeyResult, err error) {
29+
var apiKeyId server.Id
30+
var apiKey string
31+
32+
server.Tx(session.Ctx, func(tx server.PgTx) {
33+
apiKeyId = server.NewId()
34+
35+
var keyErr error
36+
apiKey, keyErr = generateApiKey()
37+
if keyErr != nil {
38+
err = keyErr
39+
return
40+
}
41+
42+
_, err = tx.Exec(
43+
session.Ctx,
44+
`
45+
INSERT INTO account_api_key
46+
(
47+
api_key_id,
48+
network_id,
49+
api_key,
50+
name
51+
)
52+
VALUES ($1, $2, $3, $4)
53+
`,
54+
apiKeyId,
55+
session.ByJwt.NetworkId,
56+
apiKey,
57+
createApiKey.Name,
58+
)
59+
})
60+
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
return &CreateApiKeyResult{
66+
Id: apiKeyId,
67+
ApiKey: apiKey,
68+
}, nil
69+
}
70+
71+
func DeleteApiKey(apiKeyId *server.Id, session *session.ClientSession) (err error) {
72+
server.Tx(session.Ctx, func(tx server.PgTx) {
73+
_, err = tx.Exec(
74+
session.Ctx,
75+
`
76+
DELETE FROM account_api_key
77+
WHERE api_key_id = $1 AND network_id = $2
78+
`,
79+
apiKeyId,
80+
session.ByJwt.NetworkId,
81+
)
82+
})
83+
return
84+
}
85+
86+
type PublicAccountApiKey struct {
87+
Id server.Id `json:"id"`
88+
Name string `json:"name"`
89+
CreateTime time.Time `json:"create_time"`
90+
}
91+
92+
/**
93+
* for dashboard listing of API keys
94+
*/
95+
func GetAccountApiKeys(session *session.ClientSession) (apiKeys []*PublicAccountApiKey, err error) {
96+
server.Tx(session.Ctx, func(tx server.PgTx) {
97+
result, err := tx.Query(
98+
session.Ctx,
99+
`
100+
SELECT api_key_id, name, create_time
101+
FROM account_api_key
102+
WHERE network_id = $1
103+
`,
104+
session.ByJwt.NetworkId,
105+
)
106+
107+
server.WithPgResult(result, err, func() {
108+
for result.Next() {
109+
var apiKeyId server.Id
110+
var name string
111+
var createTime time.Time
112+
err = result.Scan(&apiKeyId, &name, &createTime)
113+
if err != nil {
114+
return
115+
}
116+
apiKeys = append(apiKeys, &PublicAccountApiKey{
117+
Id: apiKeyId,
118+
Name: name,
119+
CreateTime: createTime,
120+
})
121+
}
122+
})
123+
})
124+
return
125+
}
126+
127+
type GetApiKeyError struct {
128+
Message string `json:"message"`
129+
}
130+
131+
type NetworkByApiKey struct {
132+
NetworkId server.Id
133+
UserId server.Id
134+
NetworkName string
135+
}
136+
137+
func GetNetworkByApiKey(apiKey string, ctx context.Context) *NetworkByApiKey {
138+
var result *NetworkByApiKey
139+
140+
server.Db(ctx, func(conn server.PgConn) {
141+
rows, err := conn.Query(
142+
ctx,
143+
`
144+
SELECT
145+
network.network_id,
146+
network.admin_user_id,
147+
network.network_name
148+
FROM account_api_key
149+
JOIN network ON network.network_id = account_api_key.network_id
150+
WHERE account_api_key.api_key = $1
151+
`,
152+
apiKey,
153+
)
154+
155+
server.WithPgResult(rows, err, func() {
156+
if rows.Next() {
157+
var r NetworkByApiKey
158+
server.RaisePgResult(rows.Scan(
159+
&r.NetworkId,
160+
&r.UserId,
161+
&r.NetworkName,
162+
), err)
163+
result = &r
164+
}
165+
})
166+
})
167+
168+
return result
169+
}
170+
171+
func generateApiKey() (string, error) {
172+
b := make([]byte, 32)
173+
if _, err := rand.Read(b); err != nil {
174+
return "", err
175+
}
176+
return fmt.Sprintf("urn_%s", hex.EncodeToString(b)), nil
177+
}
178+
179+
func init() {
180+
session.ApiKeyLookup = func(ctx context.Context, apiKey string) (server.Id, server.Id, string, bool) {
181+
n := GetNetworkByApiKey(apiKey, ctx)
182+
if n == nil {
183+
return server.Id{}, server.Id{}, "", false
184+
}
185+
return n.NetworkId, n.UserId, n.NetworkName, true
186+
}
187+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/go-playground/assert/v2"
8+
"github.com/urnetwork/server"
9+
"github.com/urnetwork/server/jwt"
10+
"github.com/urnetwork/server/session"
11+
)
12+
13+
func TestAccountApiKeys(t *testing.T) {
14+
server.DefaultTestEnv().Run(func() {
15+
16+
ctx := context.Background()
17+
18+
networkId := server.NewId()
19+
userId := server.NewId()
20+
networkName := "testnetwork"
21+
22+
Testing_CreateNetwork(ctx, networkId, networkName, userId)
23+
24+
clientId := server.NewId()
25+
userSession := session.Testing_CreateClientSession(ctx, &jwt.ByJwt{
26+
NetworkId: networkId,
27+
ClientId: &clientId,
28+
})
29+
30+
// create some api keys
31+
key1Args := CreateApiKeyArgs{
32+
Name: "key1",
33+
}
34+
key1Result, err := CreateApiKey(&key1Args, userSession)
35+
assert.Equal(t, err, nil)
36+
37+
key2Args := CreateApiKeyArgs{
38+
Name: "key2",
39+
}
40+
key2Result, err := CreateApiKey(&key2Args, userSession)
41+
assert.Equal(t, err, nil)
42+
43+
// list all account api keys
44+
keys, err := GetAccountApiKeys(userSession)
45+
assert.Equal(t, err, nil)
46+
assert.Equal(t, len(keys), 2)
47+
48+
// fetch api key
49+
network := GetNetworkByApiKey(key1Result.ApiKey, ctx)
50+
assert.NotEqual(t, network, nil)
51+
assert.Equal(t, network.NetworkId, networkId)
52+
// assert.Equal(t, key1.ApiKeyId, key1Result.Id)
53+
54+
// delete API key
55+
err = DeleteApiKey(&key1Result.Id, userSession)
56+
assert.Equal(t, err, nil)
57+
58+
// fetch deleted api key
59+
key1Deleted := GetNetworkByApiKey(key1Result.ApiKey, ctx)
60+
assert.Equal(t, key1Deleted, nil)
61+
62+
// list should now just be 1 key
63+
keys, err = GetAccountApiKeys(userSession)
64+
assert.Equal(t, err, nil)
65+
assert.Equal(t, len(keys), 1)
66+
67+
// the only remaining key should be key2
68+
assert.Equal(t, keys[0].Name, key2Args.Name)
69+
assert.Equal(t, keys[0].Id, key2Result.Id)
70+
71+
})
72+
}

router/router_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,5 +347,60 @@ func TestRouterBasic(t *testing.T) {
347347
)
348348
assert.NotEqual(t, err, nil)
349349

350+
// for API key auth testing
351+
// wire a fake API key lookup for the test
352+
networkId = server.NewId()
353+
userId = server.NewId()
354+
const testApiKey = "urn_testapikey123"
355+
356+
session.ApiKeyLookup = func(ctx context.Context, apiKey string) (server.Id, server.Id, string, bool) {
357+
if apiKey == testApiKey {
358+
return networkId, userId, "test", true
359+
}
360+
return server.Id{}, server.Id{}, "", false
361+
}
362+
363+
authApiKey := func(header http.Header) {
364+
header.Add("Authorization", fmt.Sprintf("Bearer %s", testApiKey))
365+
}
366+
367+
// valid API key should succeed on auth-required routes
368+
_, err = server.HttpGet(
369+
ctx,
370+
fmt.Sprintf("http://127.0.0.1:%d/auth", port),
371+
authApiKey,
372+
server.HttpResponseRequireStatusOk(server.ResponseJsonObject[map[string]any]),
373+
)
374+
assert.Equal(t, err, nil)
375+
376+
// API keys are non-guest, so /noguest should also succeed
377+
_, err = server.HttpGet(
378+
ctx,
379+
fmt.Sprintf("http://127.0.0.1:%d/noguest", port),
380+
authApiKey,
381+
server.HttpResponseRequireStatusOk(server.ResponseJsonObject[map[string]any]),
382+
)
383+
assert.Equal(t, err, nil)
384+
385+
// API keys have no ClientId, so /client should fail
386+
_, err = server.HttpGet(
387+
ctx,
388+
fmt.Sprintf("http://127.0.0.1:%d/client", port),
389+
authApiKey,
390+
server.HttpResponseRequireStatusOk(server.ResponseJsonObject[map[string]any]),
391+
)
392+
assert.NotEqual(t, err, nil)
393+
394+
// invalid API key should fail
395+
_, err = server.HttpGet(
396+
ctx,
397+
fmt.Sprintf("http://127.0.0.1:%d/auth", port),
398+
func(header http.Header) {
399+
header.Add("Authorization", "Bearer urn_invalid")
400+
},
401+
server.HttpResponseRequireStatusOk(server.ResponseJsonObject[map[string]any]),
402+
)
403+
assert.NotEqual(t, err, nil)
404+
350405
})
351406
}

0 commit comments

Comments
 (0)