Skip to content

Commit

Permalink
Merge pull request #140 from SkynetLabs/ivo/pub_api_keys
Browse files Browse the repository at this point in the history
Public API Keys
  • Loading branch information
ro-tex committed Mar 18, 2022
2 parents 9533fae + 614c1ed commit 6f88121
Show file tree
Hide file tree
Showing 24 changed files with 1,520 additions and 465 deletions.
192 changes: 177 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
}
// APIKeyPATCH describes the request body for updating an API key by
// providing only the requested changes
APIKeyPATCH struct {
Add []string
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 skylinks")
}
var errs []error
for _, s := range akp.Skylinks {
if !database.ValidSkylinkHash(s) {
errs = append(errs, errors.New("invalid skylink: "+s))
}
}
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,36 +113,107 @@ 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"))
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
ak, err := api.staticDB.APIKeyGet(req.Context(), akID)
// 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
}
api.WriteJSON(w, akWithKey)
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
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"))
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.StatusNotFound)
return
}
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
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"))
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)
if errors.Contains(err, mongo.ErrNoDocuments) {
api.WriteError(w, err, http.StatusNotFound)
return
}
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
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"))
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)
if errors.Contains(err, mongo.ErrNoDocuments) {
api.WriteError(w, err, http.StatusNotFound)
return
}
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
Expand Down
109 changes: 109 additions & 0 deletions api/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 {
// Public API keys can only be used with GET.
if req.Method != http.MethodGet {
return nil, nil, database.ErrInvalidAPIKey
}
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

0 comments on commit 6f88121

Please sign in to comment.