Skip to content

Commit 6f88121

Browse files
authored
Merge pull request #140 from SkynetLabs/ivo/pub_api_keys
Public API Keys
2 parents 9533fae + 614c1ed commit 6f88121

24 files changed

+1520
-465
lines changed

api/apikeys.go

+177-15
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,107 @@ package api
33
import (
44
"net/http"
55
"strconv"
6+
"time"
67

78
"github.com/SkynetLabs/skynet-accounts/database"
89
"github.com/julienschmidt/httprouter"
910
"gitlab.com/NebulousLabs/errors"
11+
"go.mongodb.org/mongo-driver/bson/primitive"
1012
"go.mongodb.org/mongo-driver/mongo"
1113
)
1214

15+
type (
16+
// APIKeyPOST describes the body of a POST request that creates an API key
17+
APIKeyPOST struct {
18+
Public bool `json:"public,string"`
19+
Skylinks []string `json:"skylinks"`
20+
}
21+
// APIKeyPUT describes the request body for updating an API key
22+
APIKeyPUT struct {
23+
Skylinks []string
24+
}
25+
// APIKeyPATCH describes the request body for updating an API key by
26+
// providing only the requested changes
27+
APIKeyPATCH struct {
28+
Add []string
29+
Remove []string
30+
}
31+
// APIKeyResponse is an API DTO which mirrors database.APIKey.
32+
APIKeyResponse struct {
33+
ID primitive.ObjectID `json:"id"`
34+
UserID primitive.ObjectID `json:"-"`
35+
Public bool `json:"public,string"`
36+
Key database.APIKey `json:"-"`
37+
Skylinks []string `json:"skylinks"`
38+
CreatedAt time.Time `json:"createdAt"`
39+
}
40+
// APIKeyResponseWithKey is an API DTO which mirrors database.APIKey but
41+
// also reveals the value of the Key field. This should only be used on key
42+
// creation.
43+
APIKeyResponseWithKey struct {
44+
APIKeyResponse
45+
Key database.APIKey `json:"key"`
46+
}
47+
)
48+
49+
// Validate checks if the request and its parts are valid.
50+
func (akp APIKeyPOST) Validate() error {
51+
if !akp.Public && len(akp.Skylinks) > 0 {
52+
return errors.New("public API keys cannot refer to skylinks")
53+
}
54+
var errs []error
55+
for _, s := range akp.Skylinks {
56+
if !database.ValidSkylinkHash(s) {
57+
errs = append(errs, errors.New("invalid skylink: "+s))
58+
}
59+
}
60+
if len(errs) > 0 {
61+
return errors.Compose(errs...)
62+
}
63+
return nil
64+
}
65+
66+
// APIKeyResponseFromAPIKey creates a new APIKeyResponse from the given API key.
67+
func APIKeyResponseFromAPIKey(ak database.APIKeyRecord) *APIKeyResponse {
68+
return &APIKeyResponse{
69+
ID: ak.ID,
70+
UserID: ak.UserID,
71+
Public: ak.Public,
72+
Key: ak.Key,
73+
Skylinks: ak.Skylinks,
74+
CreatedAt: ak.CreatedAt,
75+
}
76+
}
77+
78+
// APIKeyResponseWithKeyFromAPIKey creates a new APIKeyResponseWithKey from the
79+
// given API key.
80+
func APIKeyResponseWithKeyFromAPIKey(ak database.APIKeyRecord) *APIKeyResponseWithKey {
81+
return &APIKeyResponseWithKey{
82+
APIKeyResponse: APIKeyResponse{
83+
ID: ak.ID,
84+
UserID: ak.UserID,
85+
Public: ak.Public,
86+
Key: ak.Key,
87+
Skylinks: ak.Skylinks,
88+
CreatedAt: ak.CreatedAt,
89+
},
90+
Key: ak.Key,
91+
}
92+
}
93+
1394
// userAPIKeyPOST creates a new API key for the user.
1495
func (api *API) userAPIKeyPOST(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
15-
ak, err := api.staticDB.APIKeyCreate(req.Context(), *u)
96+
var body APIKeyPOST
97+
err := parseRequestBodyJSON(req.Body, LimitBodySizeLarge, &body)
98+
if err != nil {
99+
api.WriteError(w, err, http.StatusBadRequest)
100+
return
101+
}
102+
if err := body.Validate(); err != nil {
103+
api.WriteError(w, err, http.StatusBadRequest)
104+
return
105+
}
106+
ak, err := api.staticDB.APIKeyCreate(req.Context(), *u, body.Public, body.Skylinks)
16107
if errors.Contains(err, database.ErrMaxNumAPIKeysExceeded) {
17108
err = errors.AddContext(err, "the maximum number of API keys a user can create is "+strconv.Itoa(database.MaxNumAPIKeysPerUser))
18109
api.WriteError(w, err, http.StatusBadRequest)
@@ -22,36 +113,107 @@ func (api *API) userAPIKeyPOST(u *database.User, w http.ResponseWriter, req *htt
22113
api.WriteError(w, err, http.StatusInternalServerError)
23114
return
24115
}
25-
// Make the Key visible in JSON form. We do that with an anonymous struct
26-
// because we don't envision that being needed anywhere else in the project.
27-
akWithKey := struct {
28-
database.APIKeyRecord
29-
Key database.APIKey `bson:"key" json:"key"`
30-
}{
31-
*ak,
32-
ak.Key,
116+
api.WriteJSON(w, APIKeyResponseWithKeyFromAPIKey(*ak))
117+
}
118+
119+
// userAPIKeyGET returns a single API key.
120+
func (api *API) userAPIKeyGET(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
121+
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
122+
if err != nil {
123+
api.WriteError(w, err, http.StatusBadRequest)
124+
return
125+
}
126+
ak, err := api.staticDB.APIKeyGet(req.Context(), akID)
127+
// If there is no such API key or it doesn't exist, return a 404.
128+
if errors.Contains(err, mongo.ErrNoDocuments) || (err == nil && ak.UserID != u.ID) {
129+
api.WriteError(w, nil, http.StatusNotFound)
130+
return
33131
}
34-
api.WriteJSON(w, akWithKey)
132+
if err != nil {
133+
api.WriteError(w, err, http.StatusInternalServerError)
134+
return
135+
}
136+
api.WriteJSON(w, APIKeyResponseFromAPIKey(ak))
35137
}
36138

37-
// userAPIKeyGET lists all API keys associated with the user.
38-
func (api *API) userAPIKeyGET(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
139+
// userAPIKeyLIST lists all API keys associated with the user.
140+
func (api *API) userAPIKeyLIST(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
39141
aks, err := api.staticDB.APIKeyList(req.Context(), *u)
40142
if err != nil {
41143
api.WriteError(w, err, http.StatusInternalServerError)
42144
return
43145
}
44-
api.WriteJSON(w, aks)
146+
resp := make([]*APIKeyResponse, 0, len(aks))
147+
for _, ak := range aks {
148+
resp = append(resp, APIKeyResponseFromAPIKey(ak))
149+
}
150+
api.WriteJSON(w, resp)
45151
}
46152

47153
// userAPIKeyDELETE removes an API key.
48154
func (api *API) userAPIKeyDELETE(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
49-
akID := ps.ByName("id")
50-
err := api.staticDB.APIKeyDelete(req.Context(), *u, akID)
155+
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
156+
if err != nil {
157+
api.WriteError(w, err, http.StatusBadRequest)
158+
return
159+
}
160+
err = api.staticDB.APIKeyDelete(req.Context(), *u, akID)
51161
if err == mongo.ErrNoDocuments {
162+
api.WriteError(w, err, http.StatusNotFound)
163+
return
164+
}
165+
if err != nil {
166+
api.WriteError(w, err, http.StatusInternalServerError)
167+
return
168+
}
169+
api.WriteSuccess(w)
170+
}
171+
172+
// userAPIKeyPUT updates an API key. Only possible for public API keys.
173+
func (api *API) userAPIKeyPUT(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
174+
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
175+
if err != nil {
52176
api.WriteError(w, err, http.StatusBadRequest)
53177
return
54178
}
179+
var body APIKeyPUT
180+
err = parseRequestBodyJSON(req.Body, LimitBodySizeLarge, &body)
181+
if err != nil {
182+
api.WriteError(w, err, http.StatusBadRequest)
183+
return
184+
}
185+
err = api.staticDB.APIKeyUpdate(req.Context(), *u, akID, body.Skylinks)
186+
if errors.Contains(err, mongo.ErrNoDocuments) {
187+
api.WriteError(w, err, http.StatusNotFound)
188+
return
189+
}
190+
if err != nil {
191+
api.WriteError(w, err, http.StatusInternalServerError)
192+
return
193+
}
194+
api.WriteSuccess(w)
195+
}
196+
197+
// userAPIKeyPATCH patches an API key. The difference between PUT and PATCH is
198+
// that PATCH only specifies the changes while PUT provides the expected list of
199+
// covered skylinks. Only possible for public API keys.
200+
func (api *API) userAPIKeyPATCH(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
201+
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
202+
if err != nil {
203+
api.WriteError(w, err, http.StatusBadRequest)
204+
return
205+
}
206+
var body APIKeyPATCH
207+
err = parseRequestBodyJSON(req.Body, LimitBodySizeLarge, &body)
208+
if err != nil {
209+
api.WriteError(w, err, http.StatusBadRequest)
210+
return
211+
}
212+
err = api.staticDB.APIKeyPatch(req.Context(), *u, akID, body.Add, body.Remove)
213+
if errors.Contains(err, mongo.ErrNoDocuments) {
214+
api.WriteError(w, err, http.StatusNotFound)
215+
return
216+
}
55217
if err != nil {
56218
api.WriteError(w, err, http.StatusInternalServerError)
57219
return

api/auth.go

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
7+
"github.com/SkynetLabs/skynet-accounts/database"
8+
"github.com/SkynetLabs/skynet-accounts/jwt"
9+
jwt2 "github.com/lestrrat-go/jwx/jwt"
10+
"gitlab.com/NebulousLabs/errors"
11+
)
12+
13+
// userAndTokenByRequestToken scans the request for an authentication token,
14+
// fetches the corresponding user from the database and returns both user and
15+
// token.
16+
func (api *API) userAndTokenByRequestToken(req *http.Request) (*database.User, jwt2.Token, error) {
17+
token, err := tokenFromRequest(req)
18+
if err != nil {
19+
return nil, nil, errors.AddContext(err, "error fetching token from request")
20+
}
21+
sub, _, _, err := jwt.TokenFields(token)
22+
if err != nil {
23+
return nil, nil, errors.AddContext(err, "error decoding token from request")
24+
}
25+
u, err := api.staticDB.UserBySub(req.Context(), sub)
26+
if err != nil {
27+
return nil, nil, errors.AddContext(err, "error fetching user from database")
28+
}
29+
return u, token, nil
30+
}
31+
32+
// userAndTokenByAPIKey extracts the APIKey from the request and validates it.
33+
// It then returns the user who owns it and a token for that user.
34+
// It first checks the headers and then the query.
35+
// This method accesses the database.
36+
func (api *API) userAndTokenByAPIKey(req *http.Request) (*database.User, jwt2.Token, error) {
37+
ak, err := apiKeyFromRequest(req)
38+
if err != nil {
39+
return nil, nil, err
40+
}
41+
akr, err := api.staticDB.APIKeyByKey(req.Context(), ak.String())
42+
if err != nil {
43+
return nil, nil, err
44+
}
45+
// If we're dealing with a public API key, we need to validate that this
46+
// request is a GET for a covered skylink.
47+
if akr.Public {
48+
// Public API keys can only be used with GET.
49+
if req.Method != http.MethodGet {
50+
return nil, nil, database.ErrInvalidAPIKey
51+
}
52+
sl, err := database.ExtractSkylinkHash(req.RequestURI)
53+
if err != nil || !akr.CoversSkylink(sl) {
54+
return nil, nil, database.ErrInvalidAPIKey
55+
}
56+
}
57+
u, err := api.staticDB.UserByID(req.Context(), akr.UserID)
58+
if err != nil {
59+
return nil, nil, err
60+
}
61+
t, err := jwt.TokenForUser(u.Email, u.Sub)
62+
return u, t, err
63+
}
64+
65+
// apiKeyFromRequest extracts the API key from the request and returns it.
66+
// This function does not differentiate between APIKey and APIKey.
67+
// It first checks the headers and then the query.
68+
func apiKeyFromRequest(r *http.Request) (*database.APIKey, error) {
69+
// Check the headers for an API key.
70+
akStr := r.Header.Get(APIKeyHeader)
71+
// If there is no API key in the headers, try the query.
72+
if akStr == "" {
73+
akStr = r.FormValue("apiKey")
74+
}
75+
if akStr == "" {
76+
return nil, ErrNoAPIKey
77+
}
78+
return database.NewAPIKeyFromString(akStr)
79+
}
80+
81+
// tokenFromRequest extracts the JWT token from the request and returns it.
82+
// It first checks the authorization header and then the cookies.
83+
// The token is validated before being returned.
84+
func tokenFromRequest(r *http.Request) (jwt2.Token, error) {
85+
var tokenStr string
86+
// Check the headers for a token.
87+
parts := strings.Split(r.Header.Get("Authorization"), "Bearer")
88+
if len(parts) == 2 {
89+
tokenStr = strings.TrimSpace(parts[1])
90+
} else {
91+
// Check the cookie for a token.
92+
cookie, err := r.Cookie(CookieName)
93+
if errors.Contains(err, http.ErrNoCookie) {
94+
return nil, ErrNoToken
95+
}
96+
if err != nil {
97+
return nil, errors.AddContext(err, "cookie exists but it's not valid")
98+
}
99+
err = secureCookie.Decode(CookieName, cookie.Value, &tokenStr)
100+
if err != nil {
101+
return nil, errors.AddContext(err, "failed to decode token")
102+
}
103+
}
104+
token, err := jwt.ValidateToken(tokenStr)
105+
if err != nil {
106+
return nil, errors.AddContext(err, "failed to validate token")
107+
}
108+
return token, nil
109+
}

api/routes_test.go api/auth_test.go

+9-9
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,28 @@ func TestAPIKeyFromRequest(t *testing.T) {
2929
}
3030

3131
// API key from request form.
32-
token := randomAPIKeyString()
33-
req.Form.Add("apiKey", token)
34-
tk, err := apiKeyFromRequest(req)
32+
akStr := randomAPIKeyString()
33+
req.Form.Add("apiKey", akStr)
34+
ak, err := apiKeyFromRequest(req)
3535
if err != nil {
3636
t.Fatal(err)
3737
}
38-
if string(tk) != token {
39-
t.Fatalf("Expected '%s', got '%s'.", token, tk)
38+
if ak.String() != akStr {
39+
t.Fatalf("Expected '%s', got '%s'.", akStr, ak)
4040
}
4141

4242
// API key from headers. Expect this to take precedence over request form.
4343
token2 := randomAPIKeyString()
4444
req.Header.Set(APIKeyHeader, token2)
45-
tk, err = apiKeyFromRequest(req)
45+
ak, err = apiKeyFromRequest(req)
4646
if err != nil {
4747
t.Fatal(err)
4848
}
49-
if string(tk) == token {
49+
if ak.String() == akStr {
5050
t.Fatal("Form token took precedence over headers token.")
5151
}
52-
if string(tk) != token2 {
53-
t.Fatalf("Expected '%s', got '%s'.", token2, tk)
52+
if ak.String() != token2 {
53+
t.Fatalf("Expected '%s', got '%s'.", token2, ak)
5454
}
5555
}
5656

api/cache.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ func (utc *userTierCache) Get(sub string) (int, bool, bool) {
4747
return ce.Tier, ce.QuotaExceeded, true
4848
}
4949

50-
// Set stores the user's tier in the cache.
51-
func (utc *userTierCache) Set(u *database.User) {
50+
// Set stores the user's tier in the cache under the given key.
51+
func (utc *userTierCache) Set(key string, u *database.User) {
5252
utc.mu.Lock()
53-
utc.cache[u.Sub] = userTierCacheEntry{
53+
utc.cache[key] = userTierCacheEntry{
5454
Tier: u.Tier,
5555
QuotaExceeded: u.QuotaExceeded,
5656
ExpiresAt: time.Now().UTC().Add(userTierCacheTTL),

0 commit comments

Comments
 (0)