Skip to content

Commit

Permalink
Add support for API keys (#4515)
Browse files Browse the repository at this point in the history
* Add backend support for API keys

* Add last_used field to API keys

* Use secure random value as key secret

* Add tests for ListAPIKeys and AddAPIKey

* Cover the rest of psql_apikeys.go with tests

* Refactor the code a bit

* Storing SQL queries in a struct should ensure that
`datastore.ModifySQLStatement` gets called on all of them.
* A wrapper func around `db.Exec` reduces copypasta.
* Actually call `InitRepositoryProvider` for API keys package

* Add route handler tests using gomock

* Actually use skipper in xsrfMiddleware; minor clean-up
  • Loading branch information
ikapelyukhin authored Aug 25, 2020
1 parent 0ea6632 commit 5eb7f0e
Show file tree
Hide file tree
Showing 13 changed files with 1,219 additions and 60 deletions.
60 changes: 60 additions & 0 deletions src/jetstream/apikeys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"errors"
"net/http"

"github.com/labstack/echo"
log "github.com/sirupsen/logrus"
)

func (p *portalProxy) addAPIKey(c echo.Context) error {
log.Debug("addAPIKey")

userGUID := c.Get("user_id").(string)
comment := c.FormValue("comment")

if len(comment) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Comment can't be empty")
}

apiKey, err := p.APIKeysRepository.AddAPIKey(userGUID, comment)
if err != nil {
log.Errorf("Error adding API key: %v", err)
return errors.New("Error adding API key")
}

return c.JSON(http.StatusOK, apiKey)
}

func (p *portalProxy) listAPIKeys(c echo.Context) error {
log.Debug("listAPIKeys")

userGUID := c.Get("user_id").(string)

apiKeys, err := p.APIKeysRepository.ListAPIKeys(userGUID)
if err != nil {
log.Errorf("Error listing API keys: %v", err)
return errors.New("Error listing API keys")
}

return c.JSON(http.StatusOK, apiKeys)
}

func (p *portalProxy) deleteAPIKey(c echo.Context) error {
log.Debug("deleteAPIKey")

userGUID := c.Get("user_id").(string)
keyGUID := c.FormValue("guid")

if len(keyGUID) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "API key guid can't be empty")
}

if err := p.APIKeysRepository.DeleteAPIKey(userGUID, keyGUID); err != nil {
log.Errorf("Error deleting API key: %v", err)
return errors.New("Error deleting API key")
}

return nil
}
289 changes: 289 additions & 0 deletions src/jetstream/apikeys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package main

import (
"encoding/json"
"errors"
"net/http"
"testing"

"github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/apikeys"
"github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces"
"github.com/golang/mock/gomock"
"github.com/labstack/echo"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)

func Test_addAPIKey(t *testing.T) {
t.Parallel()

Convey("Given a request to add an API key", t, func() {
log.SetLevel(log.WarnLevel)

userID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"

Convey("when comment is not specified", func() {
req := setupMockReq("POST", "", map[string]string{})

_, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()

ctx.Set("user_id", userID)

err := pp.addAPIKey(ctx)

Convey("should return an error", func() {
So(err, ShouldResemble, echo.NewHTTPError(http.StatusBadRequest, "Comment can't be empty"))
})
})

Convey("when a DB error occurs", func() {
comment := "Test API key"

ctrl := gomock.NewController(t)
defer ctrl.Finish()

m := apikeys.NewMockRepository(ctrl)
m.
EXPECT().
AddAPIKey(gomock.Eq(userID), gomock.Eq(comment)).
Return(nil, errors.New("Something went wrong"))

req := setupMockReq("POST", "", map[string]string{
"comment": comment,
})

_, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()

pp.APIKeysRepository = m

ctx.Set("user_id", userID)

err := pp.addAPIKey(ctx)

Convey("should return an error", func() {
So(err, ShouldResemble, errors.New("Error adding API key"))
})
})

Convey("when API key comment was added successfully", func() {
comment := "Test API key"
retval := interfaces.APIKey{UserGUID: userID, Comment: comment}

ctrl := gomock.NewController(t)
defer ctrl.Finish()

m := apikeys.NewMockRepository(ctrl)
m.
EXPECT().
AddAPIKey(gomock.Eq(userID), gomock.Eq(comment)).
Return(&retval, nil)

req := setupMockReq("POST", "", map[string]string{
"comment": comment,
})

rec, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()

pp.APIKeysRepository = m

ctx.Set("user_id", userID)

err := pp.addAPIKey(ctx)

var data map[string]interface{}
if jsonErr := json.Unmarshal(rec.Body.Bytes(), &data); jsonErr != nil {
panic(jsonErr)
}

Convey("there should be no error", func() {
So(err, ShouldBeNil)
})

Convey("should return HTTP code 200", func() {
So(rec.Code, ShouldEqual, 200)
})

Convey("API key user_guid should equal context user", func() {
So(data["user_guid"], ShouldEqual, userID)
})

Convey("API key comment should equal request comment", func() {
So(data["comment"], ShouldEqual, comment)
})

Convey("API key last_used should be nil", func() {
So(data["last_used"], ShouldBeNil)
})
})

})
}

func Test_listAPIKeys(t *testing.T) {
t.Parallel()

Convey("Given a request to list API keys", t, func() {
log.SetLevel(log.WarnLevel)

userID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"

Convey("when a DB error occurs", func() {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

m := apikeys.NewMockRepository(ctrl)
m.
EXPECT().
ListAPIKeys(gomock.Eq(userID)).
Return(nil, errors.New("Something went wrong"))

req := setupMockReq("GET", "", map[string]string{})

_, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()
pp.APIKeysRepository = m

ctx.Set("user_id", userID)

err := pp.listAPIKeys(ctx)

Convey("should return an error", func() {
So(err, ShouldResemble, errors.New("Error listing API keys"))
})
})

Convey("when DB no errors occur", func() {
r1 := &interfaces.APIKey{
GUID: "00000000-0000-0000-0000-000000000000",
Secret: "",
UserGUID: userID,
Comment: "First key",
LastUsed: nil,
}

r2 := &interfaces.APIKey{
GUID: "11111111-1111-1111-1111-111111111111",
Secret: "",
UserGUID: userID,
Comment: "Second key",
LastUsed: nil,
}

retval := []interfaces.APIKey{*r1, *r2}

ctrl := gomock.NewController(t)
defer ctrl.Finish()

m := apikeys.NewMockRepository(ctrl)
m.
EXPECT().
ListAPIKeys(gomock.Eq(userID)).
Return(retval, nil)

req := setupMockReq("GET", "", map[string]string{})

rec, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()
pp.APIKeysRepository = m

ctx.Set("user_id", userID)

err := pp.listAPIKeys(ctx)

Convey("there should be no error", func() {
So(err, ShouldBeNil)
})

Convey("return valid JSON", func() {
So(rec.Body.String(), ShouldEqual, jsonMust(retval)+"\n")
})
})
})
}

func Test_deleteAPIKeys(t *testing.T) {
t.Parallel()

userID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
keyID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"

Convey("Given a request to delete an API key", t, func() {
log.SetLevel(log.PanicLevel)

Convey("when no API key GUID is supplied", func() {
req := setupMockReq("POST", "", map[string]string{
"guid": "",
})

_, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()

ctx.Set("user_id", userID)

err := pp.deleteAPIKey(ctx)

Convey("should return an error", func() {
So(err, ShouldResemble, echo.NewHTTPError(http.StatusBadRequest, "API key guid can't be empty"))
})
})

Convey("when an error occured during API key deletion", func() {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

m := apikeys.NewMockRepository(ctrl)
m.
EXPECT().
DeleteAPIKey(gomock.Eq(userID), gomock.Eq(keyID)).
Return(errors.New("Something went wrong"))

req := setupMockReq("POST", "", map[string]string{
"guid": keyID,
})

_, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()

pp.APIKeysRepository = m

ctx.Set("user_id", userID)

err := pp.deleteAPIKey(ctx)

Convey("should return an error", func() {
So(err, ShouldResemble, errors.New("Error deleting API key"))
})
})

Convey("when an API key is deleted succesfully", func() {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

m := apikeys.NewMockRepository(ctrl)
m.
EXPECT().
DeleteAPIKey(gomock.Eq(userID), gomock.Eq(keyID)).
Return(nil)

req := setupMockReq("POST", "", map[string]string{
"guid": keyID,
})

_, _, ctx, pp, db, _ := setupHTTPTest(req)
defer db.Close()

pp.APIKeysRepository = m

ctx.Set("user_id", userID)

err := pp.deleteAPIKey(ctx)

Convey("there should be no error", func() {
So(err, ShouldBeNil)
})
})
})
}
22 changes: 22 additions & 0 deletions src/jetstream/datastore/20200814140918_ApiKeys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package datastore

import (
"database/sql"

"bitbucket.org/liamstask/goose/lib/goose"
)

func init() {
RegisterMigration(20200814140918, "ApiKeys", func(txn *sql.Tx, conf *goose.DBConf) error {
apiTokenTable := "CREATE TABLE IF NOT EXISTS api_keys ("
apiTokenTable += "guid VARCHAR(36) NOT NULL UNIQUE,"
apiTokenTable += "secret VARCHAR(36) NOT NULL UNIQUE,"
apiTokenTable += "user_guid VARCHAR(36) NOT NULL,"
apiTokenTable += "comment VARCHAR(255) NOT NULL,"
apiTokenTable += "last_used TIMESTAMP,"
apiTokenTable += "PRIMARY KEY (guid) );"

_, err := txn.Exec(apiTokenTable)
return err
})
}
1 change: 1 addition & 0 deletions src/jetstream/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ require (
github.com/fatih/color v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.4.1
github.com/gogo/protobuf v1.2.1 // indirect
github.com/golang/mock v1.4.4
github.com/golang/snappy v0.0.1 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/martian v2.1.0+incompatible // indirect
Expand Down
Loading

0 comments on commit 5eb7f0e

Please sign in to comment.