From 6926bddd3a3549f8726ba07a2e67b11274e6ace4 Mon Sep 17 00:00:00 2001 From: peusebiu Date: Tue, 29 Aug 2023 19:38:38 +0300 Subject: [PATCH] feat(apikey): added route to list user api keys (#1708) adding api key expiration date Signed-off-by: Petu Eusebiu --- examples/README.md | 89 ++++++++- examples/config-minimal.json | 9 +- pkg/api/authn.go | 16 +- pkg/api/authn_test.go | 337 ++++++++++++++++++++++++++++++++- pkg/api/constants/consts.go | 3 + pkg/api/routes.go | 83 +++++++- pkg/api/routes_test.go | 139 ++++++++------ pkg/meta/boltdb/boltdb.go | 90 +++++++++ pkg/meta/dynamodb/dynamodb.go | 66 +++++++ pkg/meta/meta_test.go | 325 ++++++++++++++++++++++++------- pkg/meta/types/types.go | 20 +- pkg/test/mocks/repo_db_mock.go | 20 ++ swagger/docs.go | 33 ++++ swagger/swagger.json | 33 ++++ swagger/swagger.yaml | 22 +++ 15 files changed, 1132 insertions(+), 153 deletions(-) diff --git a/examples/README.md b/examples/README.md index d8e08ff6..eebe14a5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -331,13 +331,15 @@ Create an API key for the current user using the REST API ``` POST /auth/apikey -Body: {"label": "git", "scopes": ["repo1", "repo2"]}' +Body: {"label": "git", "scopes": ["repo1", "repo2"], "expirationDate": "2023-08-28T17:10:05+03:00"}' ``` -**Example cURL** +The time format of expirationDate is RFC1123Z. + +**Example cURL without expiration date** ```bash -curl -u user:password -X POST http://localhost:8080/auth/apikey -d '{"label": "myLabel"}' +curl -u user:password -X POST http://localhost:8080/auth/apikey -d '{"label": "git", "scopes": ["repo1", "repo2"]}' ``` **Sample output**: @@ -345,16 +347,93 @@ curl -u user:password -X POST http://localhost:8080/auth/apikey -d '{"label": "m ```json { "createdAt": "2023-05-05T15:39:28.420926+03:00", + "expirationDate": "0001-01-01T00:00:00Z", + "isExpired": false, "creatorUa": "curl/7.68.0", "generatedBy": "manual", - "lastUsed": "2023-05-05T15:39:28.4209282+03:00", + "lastUsed": "0001-01-01T00:00:00Z", "label": "git", - "scopes": null, + "scopes": [ + "repo1", + "repo2" + ], "uuid": "46a45ce7-5d92-498a-a9cb-9654b1da3da1", "apiKey": "zak_e77bcb9e9f634f1581756abbf9ecd269" } ``` +**Example cURL with expiration date** + +```bash +curl -u user:password -X POST http://localhost:8080/auth/apikey -d '{"label": "myAPIKEY", "expirationDate": "2023-08-28T17:10:05+03:00"}' +``` + +**Sample output**: + +```json +{ + "createdAt":"2023-08-28T17:09:59.2603515+03:00", + "expirationDate":"2023-08-28T17:10:05+03:00", + "isExpired":false, + "creatorUa":"curl/7.68.0", + "generatedBy":"manual", + "lastUsed":"0001-01-01T00:00:00Z", + "label":"myAPIKEY", + "scopes":null, + "uuid":"c931e635-a80d-4b52-b035-6b57be5f6e74", + "apiKey":"zak_ac55a8693d6b4370a2003fa9e10b3682" +} +``` + +##### How to get list of API Keys + +Get list of API keys for the current user using the REST API + +**Usage**: GET /auth/apikey + +**Produces**: application/json + +**Example cURL** + +```bash +curl -u user:password -X GET http://localhost:8080/auth/apikey +``` + +**Sample output**: + +```json +{ + "apiKeys": [ + { + "createdAt": "2023-05-05T15:39:28.420926+03:00", + "expirationDate": "0001-01-01T00:00:00Z", + "isExpired": true, + "creatorUa": "curl/7.68.0", + "generatedBy": "manual", + "lastUsed": "0001-01-01T00:00:00Z", + "label": "git", + "scopes": [ + "repo1", + "repo2" + ], + "uuid": "46a45ce7-5d92-498a-a9cb-9654b1da3da1" + }, + { + "createdAt": "2023-08-11T14:43:00.6459729+03:00", + "expirationDate": "2023-08-17T18:24:05+03:00", + "isExpired": false, + "creatorUa": "curl/7.68.0", + "generatedBy": "manual", + "lastUsed": "2023-08-11T14:43:47.5559998+03:00", + "label": "myAPIKEY", + "scopes": null, + "uuid": "294abf69-b62f-4e58-b214-dad2aec0bc52" + } + ] +} +``` + + ##### How to use API Keys **Using API keys with cURL** diff --git a/examples/config-minimal.json b/examples/config-minimal.json index 49f519f5..9a0635dd 100644 --- a/examples/config-minimal.json +++ b/examples/config-minimal.json @@ -5,7 +5,14 @@ }, "http": { "address": "127.0.0.1", - "port": "8080" + "port": "8080", + "auth": { + "apikey": true, + "htpasswd": { + "path": "/home/peusebiu/htpasswd" + }, + "failDelay": 5 + } }, "log": { "level": "debug" diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 8e7bb6b9..3ffb6293 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -203,7 +203,19 @@ func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, response http.ResponseW if storedIdentity == identity { ctx := getReqContextWithAuthorization(identity, []string{}, request) - err := ctlr.MetaDB.UpdateUserAPIKeyLastUsed(ctx, hashedKey) + // check if api key expired + isExpired, err := ctlr.MetaDB.IsAPIKeyExpired(ctx, hashedKey) + if err != nil { + ctlr.Log.Err(err).Str("identity", identity).Msg("can not verify if api key expired") + + return false, err + } + + if isExpired { + return false, nil + } + + err = ctlr.MetaDB.UpdateUserAPIKeyLastUsed(ctx, hashedKey) if err != nil { ctlr.Log.Err(err).Str("identity", identity).Msg("can not update user profile in DB") @@ -514,6 +526,8 @@ func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc { http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { response.WriteHeader(http.StatusBadRequest) })(w, r) + + return } /* save cookie containing state to later verify it and diff --git a/pkg/api/authn_test.go b/pkg/api/authn_test.go index 75ba202e..03cef7dc 100644 --- a/pkg/api/authn_test.go +++ b/pkg/api/authn_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "os" "testing" + "time" guuid "github.com/gofrs/uuid" "github.com/project-zot/mockoidc" @@ -38,6 +39,12 @@ type ( } ) +type ( + apiKeyListResponse struct { + APIKeys []mTypes.APIKeyDetails `json:"apiKeys"` + } +) + func TestAllowedMethodsHeaderAPIKey(t *testing.T) { defaultVal := true @@ -58,7 +65,7 @@ func TestAllowedMethodsHeaderAPIKey(t *testing.T) { resp, _ := resty.R().Options(baseURL + constants.APIKeyPath) So(resp, ShouldNotBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "POST,DELETE,OPTIONS") + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,POST,DELETE,OPTIONS") So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) }) } @@ -135,7 +142,6 @@ func TestAPIKeys(t *testing.T) { So(err, ShouldBeNil) Convey("API key retrieved with basic auth", func() { - // call endpoint with session ( added to client after previous request) resp, err := resty.R(). SetBody(reqBody). SetBasicAuth("test", "test"). @@ -161,6 +167,24 @@ func TestAPIKeys(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // get API key list with basic auth + resp, err = resty.R(). + SetBasicAuth("test", "test"). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + // add another one resp, err = resty.R(). SetBody(reqBody). @@ -179,9 +203,21 @@ func TestAPIKeys(t *testing.T) { So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list with api key auth + resp, err = resty.R(). + SetBasicAuth("test", apiKeyResponse.APIKey). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 2) }) - Convey("API key retrieved with openID", func() { + Convey("API key retrieved with openID and with no expire", func() { client := resty.New() client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) @@ -197,7 +233,6 @@ func TestAPIKeys(t *testing.T) { // call endpoint without session resp, err = client.R(). - SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) @@ -225,6 +260,25 @@ func TestAPIKeys(t *testing.T) { email := user.Email So(email, ShouldNotBeEmpty) + // get API key list + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). Get(baseURL + "/v2/_catalog") @@ -290,7 +344,16 @@ func TestAPIKeys(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) }) - Convey("Login with openid and create API key", func() { + Convey("API key retrieved with openID and with long expire", func() { + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: time.Now().Add(time.Hour).Local().Format(constants.APIKeyTimeFormat), + } + + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + client := resty.New() // mgmt should work both unauthenticated and authenticated @@ -324,6 +387,25 @@ func TestAPIKeys(t *testing.T) { err = json.Unmarshal(resp.Body(), &apiKeyResponse) So(err, ShouldBeNil) + // get API key list + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + user := mockoidc.DefaultUser() email := user.Email So(email, ShouldNotBeEmpty) @@ -354,6 +436,18 @@ func TestAPIKeys(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // get API key list + resp, err = resty.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + // invalid api keys resp, err = client.R(). SetBasicAuth("invalidEmail", apiKeyResponse.APIKey). @@ -433,6 +527,13 @@ func TestAPIKeys(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // should work with api key resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). @@ -460,6 +561,14 @@ func TestAPIKeys(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // apiKey removed, should get 401 + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + resp, err = client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). Delete(baseURL + constants.APIKeyPath) @@ -474,6 +583,25 @@ func TestAPIKeys(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 0) + resp, err = client.R(). SetBasicAuth("test", "test"). SetQueryParam("id", apiKeyResponse.UUID). @@ -490,6 +618,205 @@ func TestAPIKeys(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) }) + Convey("API key retrieved with openID and with short expire", func() { + expirationDate := time.Now().Add(1 * time.Second).Local().Round(time.Second) + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: expirationDate.Format(constants.APIKeyTimeFormat), + } + + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + client := resty.New() + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + var apiKeyResponse apiKeyResponse + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + user := mockoidc.DefaultUser() + email := user.Email + So(email, ShouldNotBeEmpty) + + // get API key list + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + So(apiKeyListResponse.APIKeys[0].IsExpired, ShouldEqual, false) + So(apiKeyListResponse.APIKeys[0].ExpirationDate.Equal(expirationDate), ShouldBeTrue) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // sleep past expire time + time.Sleep(1500 * time.Millisecond) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // again for coverage + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // get API key list with session authn + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + So(apiKeyListResponse.APIKeys[0].IsExpired, ShouldEqual, true) + So(apiKeyListResponse.APIKeys[0].ExpirationDate.Equal(expirationDate), ShouldBeTrue) + + // delete expired api key + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("id", apiKeyResponse.UUID). + Delete(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list with session authn + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 0) + }) + + Convey("Create API key with expirationDate before actual date", func() { + expirationDate := time.Now().Add(-5 * time.Second).Local().Round(time.Second) + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: expirationDate.Format(constants.APIKeyTimeFormat), + } + + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + client := resty.New() + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session ( added to client after previous request) + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) + + Convey("Create API key with unparsable expirationDate", func() { + expirationDate := time.Now().Add(-5 * time.Second).Local().Round(time.Second) + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: expirationDate.Format(time.RFC1123Z), + } + + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + client := resty.New() + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session ( added to client after previous request) + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) + Convey("Test error handling when API Key handler reads the request body", func() { request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, "baseURL", errReader(0)) diff --git a/pkg/api/constants/consts.go b/pkg/api/constants/consts.go index d7ac8999..a15b1bd9 100644 --- a/pkg/api/constants/consts.go +++ b/pkg/api/constants/consts.go @@ -1,5 +1,7 @@ package constants +import "time" + const ( ArtifactSpecRoutePrefix = "/oras/artifacts/v1" RoutePrefix = "/v2" @@ -20,4 +22,5 @@ const ( SessionClientHeaderValue = "zot-ui" APIKeysPrefix = "zak_" CallbackUIQueryParam = "callback_ui" + APIKeyTimeFormat = time.RFC3339 ) diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 396bcc9a..1bfdb4ca 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -90,10 +90,11 @@ func (rh *RouteHandler) SetupRoutes() { apiKeyRouter.Use(authHandler) apiKeyRouter.Use(BaseAuthzHandler(rh.c)) apiKeyRouter.Use(zcommon.ACHeadersMiddleware(rh.c.Config, - http.MethodPost, http.MethodDelete, http.MethodOptions)) + http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions)) apiKeyRouter.Use(zcommon.CORSHeadersMiddleware(rh.c.Config.HTTP.AllowOrigin)) apiKeyRouter.Methods(http.MethodPost, http.MethodOptions).HandlerFunc(rh.CreateAPIKey) + apiKeyRouter.Methods(http.MethodGet).HandlerFunc(rh.GetAPIKeys) apiKeyRouter.Methods(http.MethodDelete).HandlerFunc(rh.RevokeAPIKey) } @@ -2029,8 +2030,49 @@ func (rh *RouteHandler) GetOrasReferrers(response http.ResponseWriter, request * } type APIKeyPayload struct { //nolint:revive - Label string `json:"label"` - Scopes []string `json:"scopes"` + Label string `json:"label"` + Scopes []string `json:"scopes"` + ExpirationDate string `json:"expirationDate"` +} + +// GetAPIKeys godoc +// @Summary Get list of API keys for the current user +// @Description Get list of all API keys for a logged in user +// @Accept json +// @Produce json +// @Success 200 {string} string "ok" +// @Failure 401 {string} string "unauthorized" +// @Failure 500 {string} string "internal server error" +// @Router /auth/apikey [get]. +func (rh *RouteHandler) GetAPIKeys(resp http.ResponseWriter, req *http.Request) { + apiKeys, err := rh.c.MetaDB.GetUserAPIKeys(req.Context()) + if err != nil { + rh.c.Log.Error().Err(err).Msg("error getting list of API keys for user") + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + apiKeyResponse := struct { + APIKeys []mTypes.APIKeyDetails `json:"apiKeys"` + }{ + APIKeys: apiKeys, + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + + data, err := json.Marshal(apiKeyResponse) + if err != nil { + rh.c.Log.Error().Err(err).Msg("unable to marshal api key response") + + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + resp.Header().Set("Content-Type", constants.DefaultMediaType) + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write(data) } // CreateAPIKey godoc @@ -2071,14 +2113,35 @@ func (rh *RouteHandler) CreateAPIKey(resp http.ResponseWriter, req *http.Request hashedAPIKey := hashUUID(apiKey) + createdAt := time.Now() + + // won't expire if no value provided + expirationDate := time.Time{} + + if payload.ExpirationDate != "" { + expirationDate, err = time.ParseInLocation(constants.APIKeyTimeFormat, payload.ExpirationDate, time.Local) + if err != nil { + resp.WriteHeader(http.StatusBadRequest) + + return + } + + if createdAt.After(expirationDate) { + resp.WriteHeader(http.StatusBadRequest) + + return + } + } + apiKeyDetails := &mTypes.APIKeyDetails{ - CreatedAt: time.Now(), - LastUsed: time.Now(), - CreatorUA: req.UserAgent(), - GeneratedBy: "manual", - Label: payload.Label, - Scopes: payload.Scopes, - UUID: apiKeyID, + CreatedAt: createdAt, + ExpirationDate: expirationDate, + IsExpired: false, + CreatorUA: req.UserAgent(), + GeneratedBy: "manual", + Label: payload.Label, + Scopes: payload.Scopes, + UUID: apiKeyID, } err = rh.c.MetaDB.AddUserAPIKey(req.Context(), hashedAPIKey, apiKeyDetails) diff --git a/pkg/api/routes_test.go b/pkg/api/routes_test.go index 4716001d..918f3272 100644 --- a/pkg/api/routes_test.go +++ b/pkg/api/routes_test.go @@ -1416,79 +1416,112 @@ func TestRoutes(t *testing.T) { }) Convey("Test API keys", func() { - var invalid struct{} + Convey("CreateAPIKey invalid access control context", func() { + var invalid struct{} - ctx := context.TODO() - key := localCtx.GetContextKey() - ctx = context.WithValue(ctx, key, invalid) + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, invalid) - request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) - response := httptest.NewRecorder() + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) + response := httptest.NewRecorder() - rthdlr.CreateAPIKey(response, request) + rthdlr.CreateAPIKey(response, request) - resp := response.Result() - defer resp.Body.Close() - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - acCtx := localCtx.AccessControlContext{ - Username: "test", - } + request, _ = http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) + response = httptest.NewRecorder() - ctx = context.TODO() - key = localCtx.GetContextKey() - ctx = context.WithValue(ctx, key, acCtx) + rthdlr.GetAPIKeys(response, request) - request, _ = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) - response = httptest.NewRecorder() + resp = response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) - rthdlr.CreateAPIKey(response, request) + Convey("CreateAPIKey bad request body", func() { + acCtx := localCtx.AccessControlContext{ + Username: "test", + } - resp = response.Result() - defer resp.Body.Close() + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, acCtx) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) + response := httptest.NewRecorder() - payload := api.APIKeyPayload{ - Label: "test", - Scopes: []string{"test"}, - } - reqBody, err := json.Marshal(payload) - So(err, ShouldBeNil) + rthdlr.CreateAPIKey(response, request) - request, _ = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(reqBody)) - response = httptest.NewRecorder() + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) + }) - ctlr.MetaDB = mocks.MetaDBMock{ - AddUserAPIKeyFn: func(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { - return ErrUnexpectedError - }, - } - rthdlr.CreateAPIKey(response, request) + Convey("CreateAPIKey error on AddUserAPIKey", func() { + acCtx := localCtx.AccessControlContext{ + Username: "test", + } - resp = response.Result() - defer resp.Body.Close() + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, acCtx) - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + } + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) - request, _ = http.NewRequestWithContext(ctx, http.MethodDelete, baseURL, bytes.NewReader([]byte{})) - response = httptest.NewRecorder() + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(reqBody)) + response := httptest.NewRecorder() - q := request.URL.Query() - q.Add("id", "apikeyid") - request.URL.RawQuery = q.Encode() + ctlr.MetaDB = mocks.MetaDBMock{ + AddUserAPIKeyFn: func(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { + return ErrUnexpectedError + }, + } - ctlr.MetaDB = mocks.MetaDBMock{ - DeleteUserAPIKeyFn: func(ctx context.Context, id string) error { - return ErrUnexpectedError - }, - } - rthdlr.RevokeAPIKey(response, request) + rthdlr.CreateAPIKey(response, request) - resp = response.Result() - defer resp.Body.Close() + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + Convey("Revoke error on DeleteUserAPIKeyFn", func() { + acCtx := localCtx.AccessControlContext{ + Username: "test", + } + + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, acCtx) + + request, _ := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL, bytes.NewReader([]byte{})) + response := httptest.NewRecorder() + + q := request.URL.Query() + q.Add("id", "apikeyid") + request.URL.RawQuery = q.Encode() + + ctlr.MetaDB = mocks.MetaDBMock{ + DeleteUserAPIKeyFn: func(ctx context.Context, id string) error { + return ErrUnexpectedError + }, + } + + rthdlr.RevokeAPIKey(response, request) + + resp := response.Result() + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) }) Convey("Helper functions", func() { diff --git a/pkg/meta/boltdb/boltdb.go b/pkg/meta/boltdb/boltdb.go index 2b1c00a2..24d14d7a 100644 --- a/pkg/meta/boltdb/boltdb.go +++ b/pkg/meta/boltdb/boltdb.go @@ -1696,6 +1696,96 @@ func (bdw *BoltDB) UpdateUserAPIKeyLastUsed(ctx context.Context, hashedKey strin return err } +func (bdw *BoltDB) IsAPIKeyExpired(ctx context.Context, hashedKey string) (bool, error) { + acCtx, err := localCtx.GetAccessControlContext(ctx) + if err != nil { + return false, err + } + + userid := localCtx.GetUsernameFromContext(acCtx) + + if userid == "" { + // empty user is anonymous + return false, zerr.ErrUserDataNotAllowed + } + + var isExpired bool + + err = bdw.DB.Update(func(tx *bbolt.Tx) error { //nolint:varnamelen + var userData mTypes.UserData + + err := bdw.getUserData(userid, tx, &userData) + if err != nil { + return err + } + + apiKeyDetails := userData.APIKeys[hashedKey] + if apiKeyDetails.IsExpired { + isExpired = true + + return nil + } + + // if expiresAt is not nil value + if !apiKeyDetails.ExpirationDate.Equal(time.Time{}) && time.Now().After(apiKeyDetails.ExpirationDate) { + isExpired = true + apiKeyDetails.IsExpired = true + } + + userData.APIKeys[hashedKey] = apiKeyDetails + + err = bdw.setUserData(userid, tx, userData) + + return err + }) + + return isExpired, err +} + +func (bdw *BoltDB) GetUserAPIKeys(ctx context.Context) ([]mTypes.APIKeyDetails, error) { + apiKeys := make([]mTypes.APIKeyDetails, 0) + + acCtx, err := localCtx.GetAccessControlContext(ctx) + if err != nil { + return nil, err + } + + userid := localCtx.GetUsernameFromContext(acCtx) + if userid == "" { + // empty user is anonymous + return nil, zerr.ErrUserDataNotAllowed + } + + err = bdw.DB.Update(func(transaction *bbolt.Tx) error { + var userData mTypes.UserData + + err = bdw.getUserData(userid, transaction, &userData) + if err != nil && !errors.Is(err, zerr.ErrUserDataNotFound) { + return err + } + + for hashedKey, apiKeyDetails := range userData.APIKeys { + // if expiresAt is not nil value + if !apiKeyDetails.ExpirationDate.Equal(time.Time{}) && time.Now().After(apiKeyDetails.ExpirationDate) { + apiKeyDetails.IsExpired = true + } + + userData.APIKeys[hashedKey] = apiKeyDetails + + err = bdw.setUserData(userid, transaction, userData) + if err != nil { + return err + } + + apiKeys = append(apiKeys, apiKeyDetails) + } + + return nil + }) + + return apiKeys, err +} + func (bdw *BoltDB) AddUserAPIKey(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { acCtx, err := localCtx.GetAccessControlContext(ctx) if err != nil { diff --git a/pkg/meta/dynamodb/dynamodb.go b/pkg/meta/dynamodb/dynamodb.go index c5c70938..632c464f 100644 --- a/pkg/meta/dynamodb/dynamodb.go +++ b/pkg/meta/dynamodb/dynamodb.go @@ -1781,6 +1781,34 @@ func (dwr DynamoDB) GetUserGroups(ctx context.Context) ([]string, error) { return userData.Groups, err } +func (dwr *DynamoDB) IsAPIKeyExpired(ctx context.Context, hashedKey string) (bool, error) { + userData, err := dwr.GetUserData(ctx) + if err != nil { + return false, err + } + + var isExpired bool + + apiKeyDetails := userData.APIKeys[hashedKey] + if apiKeyDetails.IsExpired { + isExpired = true + + return isExpired, nil + } + + // if expiresAt is not nil value + if !apiKeyDetails.ExpirationDate.Equal(time.Time{}) && time.Now().After(apiKeyDetails.ExpirationDate) { + isExpired = true + apiKeyDetails.IsExpired = true + } + + userData.APIKeys[hashedKey] = apiKeyDetails + + err = dwr.SetUserData(ctx, userData) + + return isExpired, err +} + func (dwr DynamoDB) UpdateUserAPIKeyLastUsed(ctx context.Context, hashedKey string) error { userData, err := dwr.GetUserData(ctx) if err != nil { @@ -1797,6 +1825,44 @@ func (dwr DynamoDB) UpdateUserAPIKeyLastUsed(ctx context.Context, hashedKey stri return err } +func (dwr DynamoDB) GetUserAPIKeys(ctx context.Context) ([]mTypes.APIKeyDetails, error) { + apiKeys := make([]mTypes.APIKeyDetails, 0) + + acCtx, err := localCtx.GetAccessControlContext(ctx) + if err != nil { + return nil, err + } + + userid := localCtx.GetUsernameFromContext(acCtx) + if userid == "" { + // empty user is anonymous + return nil, zerr.ErrUserDataNotAllowed + } + + userData, err := dwr.GetUserData(ctx) + if err != nil && !errors.Is(err, zerr.ErrUserDataNotFound) { + return nil, fmt.Errorf("metaDB: error while getting userData for identity %s %w", userid, err) + } + + for hashedKey, apiKeyDetails := range userData.APIKeys { + // if expiresAt is not nil value + if !apiKeyDetails.ExpirationDate.Equal(time.Time{}) && time.Now().After(apiKeyDetails.ExpirationDate) { + apiKeyDetails.IsExpired = true + } + + userData.APIKeys[hashedKey] = apiKeyDetails + + err = dwr.SetUserData(ctx, userData) + if err != nil { + return nil, err + } + + apiKeys = append(apiKeys, apiKeyDetails) + } + + return apiKeys, nil +} + func (dwr DynamoDB) AddUserAPIKey(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { acCtx, err := localCtx.GetAccessControlContext(ctx) if err != nil { diff --git a/pkg/meta/meta_test.go b/pkg/meta/meta_test.go index 01120619..f10b6c23 100644 --- a/pkg/meta/meta_test.go +++ b/pkg/meta/meta_test.go @@ -143,10 +143,11 @@ func RunMetaDBTests(t *testing.T, metaDB mTypes.MetaDB, preparationFuncs ...func Convey("Test CRUD operations on UserData and API keys", func() { hashKey1 := "id" - hashKey2 := "key" + label1 := "apiKey1" + apiKeys := make(map[string]mTypes.APIKeyDetails) apiKeyDetails := mTypes.APIKeyDetails{ - Label: "apiKey", + Label: label1, Scopes: []string{"repo"}, UUID: hashKey1, } @@ -158,99 +159,281 @@ func RunMetaDBTests(t *testing.T, metaDB mTypes.MetaDB, preparationFuncs ...func APIKeys: apiKeys, } - authzCtxKey := localCtx.GetContextKey() + Convey("Test basic operations on API keys", func() { + hashKey2 := "key" + label2 := "apiKey2" - acCtx := localCtx.AccessControlContext{ - Username: "test", - } + authzCtxKey := localCtx.GetContextKey() + acCtx := localCtx.AccessControlContext{ + Username: "test", + } + ctx := context.WithValue(context.Background(), authzCtxKey, acCtx) - ctx := context.WithValue(context.Background(), authzCtxKey, acCtx) + err := metaDB.AddUserAPIKey(ctx, hashKey1, &apiKeyDetails) + So(err, ShouldBeNil) - err := metaDB.AddUserAPIKey(ctx, hashKey1, &apiKeyDetails) - So(err, ShouldBeNil) + isExpired, err := metaDB.IsAPIKeyExpired(ctx, hashKey1) + So(isExpired, ShouldBeFalse) + So(err, ShouldBeNil) - err = metaDB.SetUserData(ctx, userProfileSrc) - So(err, ShouldBeNil) + storedAPIKeys, err := metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0], ShouldResemble, apiKeyDetails) - userProfile, err := metaDB.GetUserData(ctx) - So(err, ShouldBeNil) - So(userProfile.Groups, ShouldResemble, userProfileSrc.Groups) - So(userProfile.APIKeys, ShouldContainKey, hashKey1) - So(userProfile.APIKeys[hashKey1].Label, ShouldEqual, apiKeyDetails.Label) - So(userProfile.APIKeys[hashKey1].Scopes, ShouldResemble, apiKeyDetails.Scopes) + userProfile, err := metaDB.GetUserData(ctx) + So(err, ShouldBeNil) + So(userProfile.APIKeys, ShouldContainKey, hashKey1) + So(userProfile.APIKeys[hashKey1].Label, ShouldEqual, apiKeyDetails.Label) + So(userProfile.APIKeys[hashKey1].Scopes, ShouldResemble, apiKeyDetails.Scopes) - lastUsed := userProfile.APIKeys[hashKey1].LastUsed + err = metaDB.SetUserData(ctx, userProfileSrc) + So(err, ShouldBeNil) - err = metaDB.UpdateUserAPIKeyLastUsed(ctx, hashKey1) - So(err, ShouldBeNil) + userProfile, err = metaDB.GetUserData(ctx) + So(err, ShouldBeNil) + So(userProfile.Groups, ShouldResemble, userProfileSrc.Groups) + So(userProfile.APIKeys, ShouldContainKey, hashKey1) + So(userProfile.APIKeys[hashKey1].Label, ShouldEqual, apiKeyDetails.Label) + So(userProfile.APIKeys[hashKey1].Scopes, ShouldResemble, apiKeyDetails.Scopes) - userProfile, err = metaDB.GetUserData(ctx) - So(err, ShouldBeNil) - So(userProfile.APIKeys[hashKey1].LastUsed, ShouldHappenAfter, lastUsed) + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0], ShouldResemble, apiKeyDetails) - userGroups, err := metaDB.GetUserGroups(ctx) - So(err, ShouldBeNil) - So(userGroups, ShouldResemble, userProfileSrc.Groups) + lastUsed := userProfile.APIKeys[hashKey1].LastUsed - apiKeyDetails.UUID = hashKey2 - err = metaDB.AddUserAPIKey(ctx, hashKey2, &apiKeyDetails) - So(err, ShouldBeNil) + err = metaDB.UpdateUserAPIKeyLastUsed(ctx, hashKey1) + So(err, ShouldBeNil) - userProfile, err = metaDB.GetUserData(ctx) - So(err, ShouldBeNil) - So(userProfile.Groups, ShouldResemble, userProfileSrc.Groups) - So(userProfile.APIKeys, ShouldContainKey, hashKey2) - So(userProfile.APIKeys[hashKey2].Label, ShouldEqual, apiKeyDetails.Label) - So(userProfile.APIKeys[hashKey2].Scopes, ShouldResemble, apiKeyDetails.Scopes) + userProfile, err = metaDB.GetUserData(ctx) + So(err, ShouldBeNil) + So(userProfile.APIKeys[hashKey1].LastUsed, ShouldHappenAfter, lastUsed) - email, err := metaDB.GetUserAPIKeyInfo(hashKey2) - So(err, ShouldBeNil) - So(email, ShouldEqual, "test") + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0].LastUsed, ShouldHappenAfter, lastUsed) - err = metaDB.DeleteUserAPIKey(ctx, hashKey1) - So(err, ShouldBeNil) + userGroups, err := metaDB.GetUserGroups(ctx) + So(err, ShouldBeNil) + So(userGroups, ShouldResemble, userProfileSrc.Groups) - userProfile, err = metaDB.GetUserData(ctx) - So(err, ShouldBeNil) - So(len(userProfile.APIKeys), ShouldEqual, 1) - So(userProfile.APIKeys, ShouldNotContainKey, hashKey1) + apiKeyDetails.UUID = hashKey2 + apiKeyDetails.Label = label2 + err = metaDB.AddUserAPIKey(ctx, hashKey2, &apiKeyDetails) + So(err, ShouldBeNil) - err = metaDB.DeleteUserAPIKey(ctx, hashKey2) - So(err, ShouldBeNil) + userProfile, err = metaDB.GetUserData(ctx) + So(err, ShouldBeNil) + So(userProfile.Groups, ShouldResemble, userProfileSrc.Groups) + So(userProfile.APIKeys, ShouldContainKey, hashKey2) + So(userProfile.APIKeys[hashKey2].Label, ShouldEqual, apiKeyDetails.Label) + So(userProfile.APIKeys[hashKey2].Scopes, ShouldResemble, apiKeyDetails.Scopes) - userProfile, err = metaDB.GetUserData(ctx) - So(err, ShouldBeNil) - So(len(userProfile.APIKeys), ShouldEqual, 0) - So(userProfile.APIKeys, ShouldNotContainKey, hashKey2) + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 2) + So(storedAPIKeys[0].Scopes, ShouldResemble, apiKeyDetails.Scopes) + So(storedAPIKeys[1].Scopes, ShouldResemble, apiKeyDetails.Scopes) + scopes := []string{storedAPIKeys[0].Label, storedAPIKeys[1].Label} + // order is not preserved when getting api keys from db + So(scopes, ShouldContain, label1) + So(scopes, ShouldContain, label2) - // delete non existent api key - err = metaDB.DeleteUserAPIKey(ctx, hashKey2) - So(err, ShouldBeNil) + email, err := metaDB.GetUserAPIKeyInfo(hashKey2) + So(err, ShouldBeNil) + So(email, ShouldEqual, "test") - err = metaDB.DeleteUserData(ctx) - So(err, ShouldBeNil) + email, err = metaDB.GetUserAPIKeyInfo(hashKey1) + So(err, ShouldBeNil) + So(email, ShouldEqual, "test") - email, err = metaDB.GetUserAPIKeyInfo(hashKey2) - So(err, ShouldNotBeNil) - So(email, ShouldBeEmpty) + err = metaDB.DeleteUserAPIKey(ctx, hashKey1) + So(err, ShouldBeNil) - email, err = metaDB.GetUserAPIKeyInfo(hashKey1) - So(err, ShouldNotBeNil) - So(email, ShouldBeEmpty) + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0].Label, ShouldEqual, label2) - _, err = metaDB.GetUserData(ctx) - So(err, ShouldNotBeNil) + userProfile, err = metaDB.GetUserData(ctx) + So(err, ShouldBeNil) + So(len(userProfile.APIKeys), ShouldEqual, 1) - userGroups, err = metaDB.GetUserGroups(ctx) - So(err, ShouldNotBeNil) - So(userGroups, ShouldBeEmpty) + err = metaDB.DeleteUserAPIKey(ctx, hashKey2) + So(err, ShouldBeNil) - err = metaDB.SetUserGroups(ctx, userProfileSrc.Groups) - So(err, ShouldBeNil) + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 0) - userGroups, err = metaDB.GetUserGroups(ctx) - So(err, ShouldBeNil) - So(userGroups, ShouldResemble, userProfileSrc.Groups) + userProfile, err = metaDB.GetUserData(ctx) + So(err, ShouldBeNil) + So(len(userProfile.APIKeys), ShouldEqual, 0) + So(userProfile.APIKeys, ShouldNotContainKey, hashKey2) + + // delete non existent api key + err = metaDB.DeleteUserAPIKey(ctx, hashKey2) + So(err, ShouldBeNil) + + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 0) + + err = metaDB.DeleteUserData(ctx) + So(err, ShouldBeNil) + + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 0) + + email, err = metaDB.GetUserAPIKeyInfo(hashKey2) + So(err, ShouldNotBeNil) + So(email, ShouldBeEmpty) + + email, err = metaDB.GetUserAPIKeyInfo(hashKey1) + So(err, ShouldNotBeNil) + So(email, ShouldBeEmpty) + + _, err = metaDB.GetUserData(ctx) + So(err, ShouldNotBeNil) + + userGroups, err = metaDB.GetUserGroups(ctx) + So(err, ShouldNotBeNil) + So(userGroups, ShouldBeEmpty) + + err = metaDB.SetUserGroups(ctx, userProfileSrc.Groups) + So(err, ShouldBeNil) + + userGroups, err = metaDB.GetUserGroups(ctx) + So(err, ShouldBeNil) + So(userGroups, ShouldResemble, userProfileSrc.Groups) + }) + + Convey("Test API keys operations with invalid access control context", func() { + var invalid struct{} + + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, invalid) + + _, err := metaDB.GetUserAPIKeys(ctx) + So(err, ShouldNotBeNil) + + err = metaDB.AddUserAPIKey(ctx, hashKey1, &apiKeyDetails) + So(err, ShouldNotBeNil) + + isExpired, err := metaDB.IsAPIKeyExpired(ctx, hashKey1) + So(isExpired, ShouldBeFalse) + So(err, ShouldNotBeNil) + + err = metaDB.DeleteUserAPIKey(ctx, hashKey1) + So(err, ShouldNotBeNil) + + _, err = metaDB.GetUserData(ctx) + So(err, ShouldNotBeNil) + + _, err = metaDB.GetUserGroups(ctx) + So(err, ShouldNotBeNil) + + _, err = metaDB.GetUserAPIKeyInfo(hashKey1) + So(err, ShouldNotBeNil) + + err = metaDB.UpdateUserAPIKeyLastUsed(ctx, hashKey1) + So(err, ShouldNotBeNil) + + err = metaDB.SetUserData(ctx, userProfileSrc) + So(err, ShouldNotBeNil) + }) + + Convey("Test API keys operations with empty userid", func() { + acCtx := localCtx.AccessControlContext{ + Username: "", + } + + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, acCtx) + + _, err := metaDB.GetUserAPIKeys(ctx) + So(err, ShouldNotBeNil) + + isExpired, err := metaDB.IsAPIKeyExpired(ctx, hashKey1) + So(isExpired, ShouldBeFalse) + So(err, ShouldNotBeNil) + + err = metaDB.AddUserAPIKey(ctx, hashKey1, &apiKeyDetails) + So(err, ShouldNotBeNil) + + err = metaDB.DeleteUserAPIKey(ctx, hashKey1) + So(err, ShouldNotBeNil) + + _, err = metaDB.GetUserData(ctx) + So(err, ShouldNotBeNil) + + _, err = metaDB.GetUserGroups(ctx) + So(err, ShouldNotBeNil) + + _, err = metaDB.GetUserAPIKeyInfo(hashKey1) + So(err, ShouldNotBeNil) + + err = metaDB.UpdateUserAPIKeyLastUsed(ctx, hashKey1) + So(err, ShouldNotBeNil) + + err = metaDB.SetUserData(ctx, userProfileSrc) + So(err, ShouldNotBeNil) + }) + + Convey("Test API keys with short expiration date", func() { + expirationDate := time.Now().Add(500 * time.Millisecond).Local().Round(time.Millisecond) + apiKeyDetails.ExpirationDate = expirationDate + + authzCtxKey := localCtx.GetContextKey() + acCtx := localCtx.AccessControlContext{ + Username: "test", + } + ctx := context.WithValue(context.Background(), authzCtxKey, acCtx) + + err := metaDB.AddUserAPIKey(ctx, hashKey1, &apiKeyDetails) + So(err, ShouldBeNil) + + storedAPIKeys, err := metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0].ExpirationDate, ShouldResemble, expirationDate) + So(storedAPIKeys[0].Label, ShouldEqual, apiKeyDetails.Label) + So(storedAPIKeys[0].Scopes, ShouldResemble, apiKeyDetails.Scopes) + + isExpired, err := metaDB.IsAPIKeyExpired(ctx, hashKey1) + So(isExpired, ShouldBeFalse) + So(err, ShouldBeNil) + + time.Sleep(600 * time.Millisecond) + + Convey("GetUserAPIKeys detects api key expired", func() { + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0].IsExpired, ShouldBeTrue) + + isExpired, err = metaDB.IsAPIKeyExpired(ctx, hashKey1) + So(isExpired, ShouldBeTrue) + So(err, ShouldBeNil) + }) + + Convey("IsAPIKeyExpired detects api key expired", func() { + isExpired, err = metaDB.IsAPIKeyExpired(ctx, hashKey1) + So(isExpired, ShouldBeTrue) + So(err, ShouldBeNil) + + storedAPIKeys, err = metaDB.GetUserAPIKeys(ctx) + So(err, ShouldBeNil) + So(len(storedAPIKeys), ShouldEqual, 1) + So(storedAPIKeys[0].IsExpired, ShouldBeTrue) + }) + }) }) Convey("Test SetManifestData and GetManifestData", func() { diff --git a/pkg/meta/types/types.go b/pkg/meta/types/types.go index c170c001..51f3aa4a 100644 --- a/pkg/meta/types/types.go +++ b/pkg/meta/types/types.go @@ -149,8 +149,12 @@ type UserDB interface { //nolint:interfacebloat GetUserAPIKeyInfo(hashedKey string) (identity string, err error) + GetUserAPIKeys(ctx context.Context) ([]APIKeyDetails, error) + AddUserAPIKey(ctx context.Context, hashedKey string, apiKeyDetails *APIKeyDetails) error + IsAPIKeyExpired(ctx context.Context, hashedKey string) (bool, error) + UpdateUserAPIKeyLastUsed(ctx context.Context, hashedKey string) error DeleteUserAPIKey(ctx context.Context, id string) error @@ -252,11 +256,13 @@ type FilterData struct { } type APIKeyDetails struct { - CreatedAt time.Time `json:"createdAt"` - CreatorUA string `json:"creatorUa"` - GeneratedBy string `json:"generatedBy"` - LastUsed time.Time `json:"lastUsed"` - Label string `json:"label"` - Scopes []string `json:"scopes"` - UUID string `json:"uuid"` + CreatedAt time.Time `json:"createdAt"` + ExpirationDate time.Time `json:"expirationDate"` + IsExpired bool `json:"isExpired"` + CreatorUA string `json:"creatorUa"` + GeneratedBy string `json:"generatedBy"` + LastUsed time.Time `json:"lastUsed"` + Label string `json:"label"` + Scopes []string `json:"scopes"` + UUID string `json:"uuid"` } diff --git a/pkg/test/mocks/repo_db_mock.go b/pkg/test/mocks/repo_db_mock.go index 1ebdced8..fd33b582 100644 --- a/pkg/test/mocks/repo_db_mock.go +++ b/pkg/test/mocks/repo_db_mock.go @@ -95,6 +95,10 @@ type MetaDBMock struct { GetUserAPIKeyInfoFn func(hashedKey string) (string, error) + IsAPIKeyExpiredFn func(ctx context.Context, hashedKey string) (bool, error) + + GetUserAPIKeysFn func(ctx context.Context) ([]mTypes.APIKeyDetails, error) + AddUserAPIKeyFn func(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error UpdateUserAPIKeyLastUsedFn func(ctx context.Context, hashedKey string) error @@ -427,6 +431,22 @@ func (sdm MetaDBMock) GetUserAPIKeyInfo(hashedKey string) (string, error) { return "", nil } +func (sdm MetaDBMock) IsAPIKeyExpired(ctx context.Context, hashedKey string) (bool, error) { + if sdm.IsAPIKeyExpiredFn != nil { + return sdm.IsAPIKeyExpiredFn(ctx, hashedKey) + } + + return false, nil +} + +func (sdm MetaDBMock) GetUserAPIKeys(ctx context.Context) ([]mTypes.APIKeyDetails, error) { + if sdm.GetUserAPIKeysFn != nil { + return sdm.GetUserAPIKeysFn(ctx) + } + + return nil, nil +} + func (sdm MetaDBMock) AddUserAPIKey(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { if sdm.AddUserAPIKeyFn != nil { return sdm.AddUserAPIKeyFn(ctx, hashedKey, apiKeyDetails) diff --git a/swagger/docs.go b/swagger/docs.go index ba9e4704..d67aa650 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -21,6 +21,36 @@ const docTemplate = `{ "basePath": "{{.BasePath}}", "paths": { "/auth/apikey": { + "get": { + "description": "Get list of all API keys for a logged in user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Get list of API keys for the current user", + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + }, "post": { "description": "Can create an api key for a logged in user, based on the provided label and scopes.", "consumes": [ @@ -1139,6 +1169,9 @@ const docTemplate = `{ "api.APIKeyPayload": { "type": "object", "properties": { + "expirationDate": { + "type": "string" + }, "label": { "type": "string" }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 0f53c922..d720a3b6 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -12,6 +12,36 @@ }, "paths": { "/auth/apikey": { + "get": { + "description": "Get list of all API keys for a logged in user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Get list of API keys for the current user", + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + }, "post": { "description": "Can create an api key for a logged in user, based on the provided label and scopes.", "consumes": [ @@ -1130,6 +1160,9 @@ "api.APIKeyPayload": { "type": "object", "properties": { + "expirationDate": { + "type": "string" + }, "label": { "type": "string" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 60b3ea56..77a66473 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1,6 +1,8 @@ definitions: api.APIKeyPayload: properties: + expirationDate: + type: string label: type: string scopes: @@ -270,6 +272,26 @@ paths: schema: type: string summary: Revokes one current user API key + get: + consumes: + - application/json + description: Get list of all API keys for a logged in user + produces: + - application/json + responses: + "200": + description: ok + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "500": + description: internal server error + schema: + type: string + summary: Get list of API keys for the current user post: consumes: - application/json