Skip to content
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

Public API Keys #140

Merged
merged 31 commits into from
Mar 18, 2022
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
503a8bc
Base implementation of public API keys.
ro-tex Feb 17, 2022
3e77471
Add some clarity what the `env` image does.
ro-tex Feb 25, 2022
3890d4f
Handle public api keys during authentication.
ro-tex Feb 25, 2022
d375f02
Merge branch 'main' into ivo/pub_api_keys
ro-tex Feb 25, 2022
4d1b4e6
Fix a broken test (needs a custom struct to get a hidden field).
ro-tex Feb 25, 2022
1fd0fe9
Merge branch 'main' into ivo/pub_api_keys
ro-tex Feb 28, 2022
a6c3a34
Merge branch 'main' into ivo/pub_api_keys
ro-tex Mar 1, 2022
d4ed081
Move public API keys to their own set of endpoints, as they are suffi…
ro-tex Mar 2, 2022
61fed0f
Add a custom endpoint for checking speed limits based on public API k…
ro-tex Mar 2, 2022
a68a2f2
Refactor the user tier cache to always require a key and pass it befo…
ro-tex Mar 2, 2022
e7a4bee
Merge public and private API keys into one.
ro-tex Mar 3, 2022
f9cbcba
Add integration tests for public API Keys.
ro-tex Mar 4, 2022
e40ad87
Merge branch 'main' into ivo/pub_api_keys
ro-tex Mar 9, 2022
7f9c495
Let `userLimitsGetFromTier` handle quota exceeded.
ro-tex Mar 9, 2022
c0a6904
Let `apiKeyFromRequest` return a validated API key, thus simplifying …
ro-tex Mar 9, 2022
2a4059b
Add HealthGET tester method.
ro-tex Mar 10, 2022
28978d6
Add an integration test for public API keys usage.
ro-tex Mar 10, 2022
f491de4
Tester helpers for API keys.
ro-tex Mar 10, 2022
87b8eef
Add the rest of the API key integration tests.
ro-tex Mar 11, 2022
b32108d
Clean up.
ro-tex Mar 11, 2022
fb443e6
APIKeyPOST.Validate() returns descriptive errors.
ro-tex Mar 15, 2022
c0100d5
Address PR comments.
ro-tex Mar 15, 2022
d79e743
Move DB schema to a separate file.
ro-tex Mar 15, 2022
f0eaaac
Fix a nullpointer in tester.
ro-tex Mar 16, 2022
26a2332
Unparallelise tests that lead to data races.
ro-tex Mar 16, 2022
0323412
Update api/apikeys.go
ro-tex Mar 17, 2022
11e46ae
Update api/apikeys.go
ro-tex Mar 17, 2022
d24e0ff
Update api/apikeys.go
ro-tex Mar 17, 2022
26cdf8a
Merge branch 'main' into ivo/pub_api_keys
ro-tex Mar 17, 2022
f4bc82e
Add 404 errors.
ro-tex Mar 17, 2022
614c1ed
Merge branch 'main' into ivo/pub_api_keys
ro-tex Mar 18, 2022
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
184 changes: 169 additions & 15 deletions api/apikeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,107 @@ package api
import (
"net/http"
"strconv"
"time"

"github.com/SkynetLabs/skynet-accounts/database"
"github.com/julienschmidt/httprouter"
"gitlab.com/NebulousLabs/errors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)

type (
// APIKeyPOST describes the body of a POST request that creates an API key
APIKeyPOST struct {
Public bool `json:"public,string"`
Skylinks []string `json:"skylinks"`
}
// APIKeyPUT describes the request body for updating an API key
APIKeyPUT struct {
Skylinks []string
peterjan marked this conversation as resolved.
Show resolved Hide resolved
}
// APIKeyPATCH describes the request body for updating an API key by
// providing only the requested changes
APIKeyPATCH struct {
Add []string
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
Remove []string
}
// APIKeyResponse is an API DTO which mirrors database.APIKey.
APIKeyResponse struct {
ID primitive.ObjectID `json:"id"`
UserID primitive.ObjectID `json:"-"`
Public bool `json:"public,string"`
Key database.APIKey `json:"-"`
Skylinks []string `json:"skylinks"`
CreatedAt time.Time `json:"createdAt"`
}
// APIKeyResponseWithKey is an API DTO which mirrors database.APIKey but
// also reveals the value of the Key field. This should only be used on key
// creation.
APIKeyResponseWithKey struct {
APIKeyResponse
Key database.APIKey `json:"key"`
}
)

// Validate checks if the request and its parts are valid.
func (akp APIKeyPOST) Validate() error {
if !akp.Public && len(akp.Skylinks) > 0 {
return errors.New("public API keys cannot refer to skylinlks")
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
}
errs := make([]error, 0)
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
for _, s := range akp.Skylinks {
if !database.ValidSkylinkHash(s) {
errs = append(errs, errors.New("invalid skylink:"+s))
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
}
}
if len(errs) > 0 {
return errors.Compose(errs...)
}
return nil
}

// APIKeyResponseFromAPIKey creates a new APIKeyResponse from the given API key.
func APIKeyResponseFromAPIKey(ak database.APIKeyRecord) *APIKeyResponse {
return &APIKeyResponse{
ID: ak.ID,
UserID: ak.UserID,
Public: ak.Public,
Key: ak.Key,
Skylinks: ak.Skylinks,
CreatedAt: ak.CreatedAt,
}
}

// APIKeyResponseWithKeyFromAPIKey creates a new APIKeyResponseWithKey from the
// given API key.
func APIKeyResponseWithKeyFromAPIKey(ak database.APIKeyRecord) *APIKeyResponseWithKey {
return &APIKeyResponseWithKey{
APIKeyResponse: APIKeyResponse{
ID: ak.ID,
UserID: ak.UserID,
Public: ak.Public,
Key: ak.Key,
Skylinks: ak.Skylinks,
CreatedAt: ak.CreatedAt,
},
Key: ak.Key,
}
}

// userAPIKeyPOST creates a new API key for the user.
func (api *API) userAPIKeyPOST(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
ak, err := api.staticDB.APIKeyCreate(req.Context(), *u)
var body APIKeyPOST
err := parseRequestBodyJSON(req.Body, LimitBodySizeLarge, &body)
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
if err := body.Validate(); err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
ak, err := api.staticDB.APIKeyCreate(req.Context(), *u, body.Public, body.Skylinks)
if errors.Contains(err, database.ErrMaxNumAPIKeysExceeded) {
err = errors.AddContext(err, "the maximum number of API keys a user can create is "+strconv.Itoa(database.MaxNumAPIKeysPerUser))
api.WriteError(w, err, http.StatusBadRequest)
Expand All @@ -22,32 +113,51 @@ func (api *API) userAPIKeyPOST(u *database.User, w http.ResponseWriter, req *htt
api.WriteError(w, err, http.StatusInternalServerError)
return
}
// Make the Key visible in JSON form. We do that with an anonymous struct
// because we don't envision that being needed anywhere else in the project.
akWithKey := struct {
database.APIKeyRecord
Key database.APIKey `bson:"key" json:"key"`
}{
*ak,
ak.Key,
api.WriteJSON(w, APIKeyResponseWithKeyFromAPIKey(*ak))
}

// userAPIKeyGET returns a single API key.
func (api *API) userAPIKeyGET(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
peterjan marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
ak, err := api.staticDB.APIKeyGet(req.Context(), akID)
peterjan marked this conversation as resolved.
Show resolved Hide resolved
// If there is no such API key or it doesn't exist, return a 404.
if errors.Contains(err, mongo.ErrNoDocuments) || (err == nil && ak.UserID != u.ID) {
api.WriteError(w, nil, http.StatusNotFound)
return
}
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
api.WriteJSON(w, akWithKey)
api.WriteJSON(w, APIKeyResponseFromAPIKey(ak))
}

// userAPIKeyGET lists all API keys associated with the user.
func (api *API) userAPIKeyGET(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
// userAPIKeyLIST lists all API keys associated with the user.
func (api *API) userAPIKeyLIST(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
aks, err := api.staticDB.APIKeyList(req.Context(), *u)
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
api.WriteJSON(w, aks)
resp := make([]*APIKeyResponse, 0, len(aks))
for _, ak := range aks {
resp = append(resp, APIKeyResponseFromAPIKey(ak))
}
api.WriteJSON(w, resp)
}

// userAPIKeyDELETE removes an API key.
func (api *API) userAPIKeyDELETE(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
akID := ps.ByName("id")
err := api.staticDB.APIKeyDelete(req.Context(), *u, akID)
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
err = api.staticDB.APIKeyDelete(req.Context(), *u, akID)
if err == mongo.ErrNoDocuments {
api.WriteError(w, err, http.StatusBadRequest)
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
return
Expand All @@ -58,3 +168,47 @@ func (api *API) userAPIKeyDELETE(u *database.User, w http.ResponseWriter, req *h
}
api.WriteSuccess(w)
}

// userAPIKeyPUT updates an API key. Only possible for public API keys.
func (api *API) userAPIKeyPUT(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
var body APIKeyPUT
err = parseRequestBodyJSON(req.Body, LimitBodySizeLarge, &body)
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
err = api.staticDB.APIKeyUpdate(req.Context(), *u, akID, body.Skylinks)
peterjan marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
return
}
api.WriteSuccess(w)
}

// userAPIKeyPATCH patches an API key. The difference between PUT and PATCH is
// that PATCH only specifies the changes while PUT provides the expected list of
// covered skylinks. Only possible for public API keys.
func (api *API) userAPIKeyPATCH(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
akID, err := primitive.ObjectIDFromHex(ps.ByName("id"))
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
var body APIKeyPATCH
err = parseRequestBodyJSON(req.Body, LimitBodySizeLarge, &body)
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
err = api.staticDB.APIKeyPatch(req.Context(), *u, akID, body.Add, body.Remove)
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
api.WriteSuccess(w)
}
105 changes: 105 additions & 0 deletions api/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package api

import (
"net/http"
"strings"

"github.com/SkynetLabs/skynet-accounts/database"
"github.com/SkynetLabs/skynet-accounts/jwt"
jwt2 "github.com/lestrrat-go/jwx/jwt"
"gitlab.com/NebulousLabs/errors"
)

// userAndTokenByRequestToken scans the request for an authentication token,
// fetches the corresponding user from the database and returns both user and
// token.
func (api *API) userAndTokenByRequestToken(req *http.Request) (*database.User, jwt2.Token, error) {
token, err := tokenFromRequest(req)
if err != nil {
return nil, nil, errors.AddContext(err, "error fetching token from request")
}
sub, _, _, err := jwt.TokenFields(token)
if err != nil {
return nil, nil, errors.AddContext(err, "error decoding token from request")
}
u, err := api.staticDB.UserBySub(req.Context(), sub)
if err != nil {
return nil, nil, errors.AddContext(err, "error fetching user from database")
}
return u, token, nil
}

// userAndTokenByAPIKey extracts the APIKey from the request and validates it.
// It then returns the user who owns it and a token for that user.
// It first checks the headers and then the query.
// This method accesses the database.
func (api *API) userAndTokenByAPIKey(req *http.Request) (*database.User, jwt2.Token, error) {
ak, err := apiKeyFromRequest(req)
if err != nil {
return nil, nil, err
}
akr, err := api.staticDB.APIKeyByKey(req.Context(), ak.String())
if err != nil {
return nil, nil, err
}
// If we're dealing with a public API key, we need to validate that this
// request is a GET for a covered skylink.
if akr.Public {
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
sl, err := database.ExtractSkylinkHash(req.RequestURI)
if err != nil || !akr.CoversSkylink(sl) {
return nil, nil, database.ErrInvalidAPIKey
}
}
u, err := api.staticDB.UserByID(req.Context(), akr.UserID)
if err != nil {
return nil, nil, err
}
t, err := jwt.TokenForUser(u.Email, u.Sub)
return u, t, err
}

// apiKeyFromRequest extracts the API key from the request and returns it.
// This function does not differentiate between APIKey and APIKey.
// It first checks the headers and then the query.
func apiKeyFromRequest(r *http.Request) (*database.APIKey, error) {
// Check the headers for an API key.
akStr := r.Header.Get(APIKeyHeader)
// If there is no API key in the headers, try the query.
if akStr == "" {
akStr = r.FormValue("apiKey")
}
if akStr == "" {
return nil, ErrNoAPIKey
}
return database.NewAPIKeyFromString(akStr)
}

// tokenFromRequest extracts the JWT token from the request and returns it.
// It first checks the authorization header and then the cookies.
// The token is validated before being returned.
func tokenFromRequest(r *http.Request) (jwt2.Token, error) {
var tokenStr string
// Check the headers for a token.
parts := strings.Split(r.Header.Get("Authorization"), "Bearer")
if len(parts) == 2 {
tokenStr = strings.TrimSpace(parts[1])
} else {
// Check the cookie for a token.
cookie, err := r.Cookie(CookieName)
if errors.Contains(err, http.ErrNoCookie) {
return nil, ErrNoToken
}
if err != nil {
return nil, errors.AddContext(err, "cookie exists but it's not valid")
}
err = secureCookie.Decode(CookieName, cookie.Value, &tokenStr)
if err != nil {
return nil, errors.AddContext(err, "failed to decode token")
}
}
token, err := jwt.ValidateToken(tokenStr)
if err != nil {
return nil, errors.AddContext(err, "failed to validate token")
}
return token, nil
}
18 changes: 9 additions & 9 deletions api/routes_test.go → api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,28 @@ func TestAPIKeyFromRequest(t *testing.T) {
}

// API key from request form.
token := randomAPIKeyString()
req.Form.Add("apiKey", token)
tk, err := apiKeyFromRequest(req)
akStr := randomAPIKeyString()
req.Form.Add("apiKey", akStr)
ak, err := apiKeyFromRequest(req)
if err != nil {
t.Fatal(err)
}
if string(tk) != token {
t.Fatalf("Expected '%s', got '%s'.", token, tk)
if ak.String() != akStr {
t.Fatalf("Expected '%s', got '%s'.", akStr, ak)
}

// API key from headers. Expect this to take precedence over request form.
token2 := randomAPIKeyString()
req.Header.Set(APIKeyHeader, token2)
tk, err = apiKeyFromRequest(req)
ak, err = apiKeyFromRequest(req)
if err != nil {
t.Fatal(err)
}
if string(tk) == token {
if ak.String() == akStr {
t.Fatal("Form token took precedence over headers token.")
}
if string(tk) != token2 {
t.Fatalf("Expected '%s', got '%s'.", token2, tk)
if ak.String() != token2 {
t.Fatalf("Expected '%s', got '%s'.", token2, ak)
}
}

Expand Down
6 changes: 3 additions & 3 deletions api/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ func (utc *userTierCache) Get(sub string) (int, bool, bool) {
return ce.Tier, ce.QuotaExceeded, true
}

// Set stores the user's tier in the cache.
func (utc *userTierCache) Set(u *database.User) {
// Set stores the user's tier in the cache under the given key.
func (utc *userTierCache) Set(key string, u *database.User) {
utc.mu.Lock()
utc.cache[u.Sub] = userTierCacheEntry{
utc.cache[key] = userTierCacheEntry{
Tier: u.Tier,
QuotaExceeded: u.QuotaExceeded,
ExpiresAt: time.Now().UTC().Add(userTierCacheTTL),
Expand Down
Loading