feat: integrate openID auth logic and user profile management (#1381)

This change introduces OpenID authn by using providers such as Github,
Gitlab, Google and Dex.
User sessions are now used for web clients to identify
and persist an authenticated users session, thus not requiring every request to
use credentials.
Another change is apikey feature, users can create/revoke their api keys and use them
to authenticate when using cli clients such as skopeo.

eg:
login:
/auth/login?provider=github
/auth/login?provider=gitlab
and so on

logout:
/auth/logout

redirectURL:
/auth/callback/github
/auth/callback/gitlab
and so on

If network policy doesn't allow inbound connections, this callback wont work!

for more info read documentation added in this commit.

Signed-off-by: Alex Stan <alexandrustan96@yahoo.ro>
Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
Co-authored-by: Alex Stan <alexandrustan96@yahoo.ro>
This commit is contained in:
peusebiu
2023-07-07 19:27:10 +03:00
committed by GitHub
parent 5494a1b8d6
commit 17d1338af1
51 changed files with 5467 additions and 624 deletions
+66
View File
@@ -0,0 +1,66 @@
# `API keys`
zot allows authentication for REST API calls using your API key as an alternative to your password.
* User can create/revoke his API key.
* Can not be retrieved, it is shown to the user only the first time is created.
* An API key has the same rights as the user who generated it.
## API keys REST API
### Create API Key
**Description**: Create an API key for the current user.
**Usage**: POST /v2/_zot/ext/apikey
**Produces**: application/json
**Sample input**:
```
POST /api/security/apiKey
Body: {"label": "git", "scopes": ["repo1", "repo2"]}'
```
**Example cURL**
```
curl -u user:password -X POST http://localhost:8080/v2/_zot/ext/apikey -d '{"label": "myLabel", "scopes": ["repo1", "repo2"]}'
```
**Sample output**:
```json
{
"createdAt": "2023-05-05T15:39:28.420926+03:00",
"creatorUa": "curl/7.68.0",
"generatedBy": "manual",
"lastUsed": "2023-05-05T15:39:28.4209282+03:00",
"label": "git",
"scopes": [
"repo1",
"repo2"
],
"uuid": "46a45ce7-5d92-498a-a9cb-9654b1da3da1",
"apiKey": "zak_e77bcb9e9f634f1581756abbf9ecd269"
}
```
**Using API keys cURL**
```
curl -u user:zak_e77bcb9e9f634f1581756abbf9ecd269 http://localhost:8080/v2/_catalog
```
### Revoke API Key
**Description**: Revokes one current user API key by api key UUID
**Usage**: DELETE /api/security/apiKey?id=$uuid
**Produces**: application/json
**Example cURL**
```
curl -u user:password -X DELETE http://localhost:8080/v2/_zot/ext/apikey?id=46a45ce7-5d92-498a-a9cb-9654b1da3da1
```
+1
View File
@@ -8,6 +8,7 @@ Component | Endpoint | Description
[`search`](search/search.md) | `/v2/_zot/ext/search` | efficient and enhanced registry search capabilities using graphQL backend
[`mgmt`](mgmt.md) | `/v2/_zot/ext/mgmt` | config management
[`userprefs`](userprefs.md) | `/v2/_zot/ext/userprefs` | change user preferences
[`apikey`](README_apikey.md) | `/v2/_zot/ext/apikey` | user api keys management
# References
+5
View File
@@ -19,6 +19,11 @@ type ExtensionConfig struct {
Lint *LintConfig
UI *UIConfig
Mgmt *MgmtConfig
APIKey *APIKeyConfig
}
type APIKeyConfig struct {
BaseConfig `mapstructure:",squash"`
}
type MgmtConfig struct {
+197
View File
@@ -0,0 +1,197 @@
//go:build apikey
// +build apikey
package extensions
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
guuid "github.com/gofrs/uuid"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
jsoniter "github.com/json-iterator/go"
godigest "github.com/opencontainers/go-digest"
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/api/constants"
zcommon "zotregistry.io/zot/pkg/common"
"zotregistry.io/zot/pkg/log"
"zotregistry.io/zot/pkg/meta/repodb"
)
func SetupAPIKeyRoutes(config *config.Config, router *mux.Router, repoDB repodb.RepoDB,
cookieStore sessions.Store, log log.Logger,
) {
if config.Extensions.APIKey != nil && *config.Extensions.APIKey.Enable {
log.Info().Msg("setting up api key routes")
allowedMethods := zcommon.AllowedMethods(http.MethodPost, http.MethodDelete)
apiKeyRouter := router.PathPrefix(constants.ExtAPIKey).Subrouter()
apiKeyRouter.Use(zcommon.ACHeadersHandler(allowedMethods...))
apiKeyRouter.Use(zcommon.AddExtensionSecurityHeaders())
apiKeyRouter.Methods(allowedMethods...).Handler(HandleAPIKeyRequest(repoDB, cookieStore, log))
}
}
type APIKeyPayload struct { //nolint:revive
Label string `json:"label"`
Scopes []string `json:"scopes"`
}
func HandleAPIKeyRequest(repoDB repodb.RepoDB, cookieStore sessions.Store,
log log.Logger,
) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodPost:
CreateAPIKey(resp, req, repoDB, cookieStore, log) //nolint:contextcheck
return
case http.MethodDelete:
RevokeAPIKey(resp, req, repoDB, cookieStore, log) //nolint:contextcheck
return
}
})
}
// CreateAPIKey godoc
// @Summary Create an API key for the current user
// @Description Can create an api key for a logged in user, based on the provided label and scopes.
// @Accept json
// @Produce json
// @Success 201 {string} string "created"
// @Failure 401 {string} string "unauthorized"
// @Failure 500 {string} string "internal server error"
// @Router /v2/_zot/ext/apikey [post].
func CreateAPIKey(resp http.ResponseWriter, req *http.Request, repoDB repodb.RepoDB,
cookieStore sessions.Store, log log.Logger,
) {
var payload APIKeyPayload
body, err := io.ReadAll(req.Body)
if err != nil {
log.Error().Msg("unable to read request body")
resp.WriteHeader(http.StatusInternalServerError)
return
}
err = json.Unmarshal(body, &payload)
if err != nil {
log.Error().Err(err).Msg("unable to unmarshal body")
resp.WriteHeader(http.StatusInternalServerError)
return
}
apiKeyBase, err := guuid.NewV4()
if err != nil {
log.Error().Err(err).Msg("unable to generate uuid")
resp.WriteHeader(http.StatusInternalServerError)
return
}
apiKey := strings.ReplaceAll(apiKeyBase.String(), "-", "")
hashedAPIKey := hashUUID(apiKey)
// will be used for identifying a specific api key
apiKeyID, err := guuid.NewV4()
if err != nil {
log.Error().Err(err).Msg("unable to generate uuid")
resp.WriteHeader(http.StatusInternalServerError)
return
}
apiKeyDetails := &repodb.APIKeyDetails{
CreatedAt: time.Now(),
LastUsed: time.Now(),
CreatorUA: req.UserAgent(),
GeneratedBy: "manual",
Label: payload.Label,
Scopes: payload.Scopes,
UUID: apiKeyID.String(),
}
err = repoDB.AddUserAPIKey(req.Context(), hashedAPIKey, apiKeyDetails)
if err != nil {
log.Error().Err(err).Msg("error storing API key")
resp.WriteHeader(http.StatusInternalServerError)
return
}
apiKeyResponse := struct {
repodb.APIKeyDetails
APIKey string `json:"apiKey"`
}{
APIKey: fmt.Sprintf("%s%s", constants.APIKeysPrefix, apiKey),
APIKeyDetails: *apiKeyDetails,
}
json := jsoniter.ConfigCompatibleWithStandardLibrary
data, err := json.Marshal(apiKeyResponse)
if err != nil {
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.StatusCreated)
_, _ = resp.Write(data)
}
// RevokeAPIKey godoc
// @Summary Revokes one current user API key
// @Description Revokes one current user API key based on given key ID
// @Accept json
// @Produce json
// @Param id path string true "api token id (UUID)"
// @Success 200 {string} string "ok"
// @Failure 500 {string} string "internal server error"
// @Failure 401 {string} string "unauthorized"
// @Failure 400 {string} string "bad request"
// @Router /v2/_zot/ext/apikey?id=UUID [delete].
func RevokeAPIKey(resp http.ResponseWriter, req *http.Request, repoDB repodb.RepoDB,
cookieStore sessions.Store, log log.Logger,
) {
ids, ok := req.URL.Query()["id"]
if !ok || len(ids) != 1 {
resp.WriteHeader(http.StatusBadRequest)
return
}
keyID := ids[0]
err := repoDB.DeleteUserAPIKey(req.Context(), keyID)
if err != nil {
log.Error().Err(err).Str("keyID", keyID).Msg("error deleting API key")
resp.WriteHeader(http.StatusInternalServerError)
return
}
resp.WriteHeader(http.StatusOK)
}
func hashUUID(uuid string) string {
digester := sha256.New()
digester.Write([]byte(uuid))
return godigest.NewDigestFromEncoded(godigest.SHA256, fmt.Sprintf("%x", digester.Sum(nil))).Encoded()
}
@@ -0,0 +1,20 @@
//go:build !apikey
// +build !apikey
package extensions
import (
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/log"
"zotregistry.io/zot/pkg/meta/repodb"
)
func SetupAPIKeyRoutes(config *config.Config, router *mux.Router, repoDB repodb.RepoDB,
cookieStore sessions.Store, log log.Logger,
) {
log.Warn().Msg("skipping setting up API key routes because given zot binary doesn't include this feature," +
"please build a binary that does so")
}
+531
View File
@@ -0,0 +1,531 @@
//go:build apikey
// +build apikey
package extensions_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"testing"
"github.com/project-zot/mockoidc"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/resty.v1"
"zotregistry.io/zot/pkg/api"
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/api/constants"
"zotregistry.io/zot/pkg/extensions"
extconf "zotregistry.io/zot/pkg/extensions/config"
"zotregistry.io/zot/pkg/meta/repodb"
localCtx "zotregistry.io/zot/pkg/requestcontext"
"zotregistry.io/zot/pkg/test"
"zotregistry.io/zot/pkg/test/mocks"
)
type (
apiKeyResponse struct {
repodb.APIKeyDetails
APIKey string `json:"apiKey"`
}
)
var ErrUnexpectedError = errors.New("unexpected err")
func TestAPIKeys(t *testing.T) {
Convey("Make a new controller", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
htpasswdPath := test.MakeHtpasswdFile()
defer os.Remove(htpasswdPath)
mockOIDCServer, err := test.MockOIDCRun()
if err != nil {
panic(err)
}
defer func() {
err := mockOIDCServer.Shutdown()
if err != nil {
panic(err)
}
}()
mockOIDCConfig := mockOIDCServer.Config()
conf.HTTP.Auth = &config.AuthConfig{
HTPasswd: config.AuthHTPasswd{
Path: htpasswdPath,
},
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{
"dex": {
ClientID: mockOIDCConfig.ClientID,
ClientSecret: mockOIDCConfig.ClientSecret,
KeyPath: "",
Issuer: mockOIDCConfig.Issuer,
Scopes: []string{"openid", "email", "groups"},
},
},
},
}
conf.HTTP.AccessControl = &config.AccessControlConfig{}
defaultVal := true
apiKeyConfig := &extconf.APIKeyConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
}
mgmtConfg := &extconf.MgmtConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
}
conf.Extensions = &extconf.ExtensionConfig{
APIKey: apiKeyConfig,
Mgmt: mgmtConfg,
}
ctlr := api.NewController(conf)
dir := t.TempDir()
ctlr.Config.Storage.RootDirectory = dir
cm := test.NewControllerManager(ctlr)
cm.StartServer()
defer cm.StopServer()
test.WaitTillServerReady(baseURL)
payload := extensions.APIKeyPayload{
Label: "test",
Scopes: []string{"test"},
}
reqBody, err := json.Marshal(payload)
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").
Post(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
user := mockoidc.DefaultUser()
// get API key and email from apikey route response
var apiKeyResponse apiKeyResponse
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
So(err, ShouldBeNil)
email := user.Email
So(email, ShouldNotBeEmpty)
resp, err = resty.R().
SetBasicAuth("test", apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// add another one
resp, err = resty.R().
SetBody(reqBody).
SetBasicAuth("test", "test").
Post(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
So(err, ShouldBeNil)
resp, err = resty.R().
SetBasicAuth("test", apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
})
Convey("API key retrieved with openID", func() {
client := resty.New()
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
// first login user
resp, err := client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
SetQueryParam("provider", "dex").
Get(baseURL + constants.LoginPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
cookies := resp.Cookies()
// call endpoint without session
resp, err = client.R().
SetBody(reqBody).
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
Post(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
client.SetCookies(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.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
user := mockoidc.DefaultUser()
// get API key and email from apikey route response
var apiKeyResponse apiKeyResponse
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
So(err, ShouldBeNil)
email := user.Email
So(email, ShouldNotBeEmpty)
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// trigger errors
ctlr.RepoDB = mocks.RepoDBMock{
GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) {
return "", ErrUnexpectedError
},
}
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
ctlr.RepoDB = mocks.RepoDBMock{
GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) {
return user.Email, nil
},
GetUserGroupsFn: func(ctx context.Context) ([]string, error) {
return []string{}, ErrUnexpectedError
},
}
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
ctlr.RepoDB = mocks.RepoDBMock{
GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) {
return user.Email, nil
},
UpdateUserAPIKeyLastUsedFn: func(ctx context.Context, hashedKey string) error {
return ErrUnexpectedError
},
}
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
client = resty.New()
// call endpoint without session
resp, err = client.R().
SetBody(reqBody).
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
Post(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
})
Convey("Login with openid and create API key", func() {
client := resty.New()
// mgmt should work both unauthenticated and authenticated
resp, err := client.R().
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
// first login user
resp, err = client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
SetQueryParam("provider", "dex").
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.FullAPIKeyPrefix)
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)
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// auth with API key
// we need new client without session cookie set
client = resty.New()
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// invalid api keys
resp, err = client.R().
SetBasicAuth("invalidEmail", apiKeyResponse.APIKey).
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
resp, err = client.R().
SetBasicAuth(email, "noprefixAPIKey").
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
resp, err = client.R().
SetBasicAuth(email, "zak_notworkingAPIKey").
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
authzCtxKey := localCtx.GetContextKey()
acCtx := localCtx.AccessControlContext{
Username: email,
}
ctx := context.WithValue(context.Background(), authzCtxKey, acCtx)
err = ctlr.RepoDB.DeleteUserData(ctx)
So(err, ShouldBeNil)
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
client = resty.New()
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
// without creds should work
resp, err = client.R().
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// login again
resp, err = client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
SetQueryParam("provider", "dex").
Get(baseURL + constants.LoginPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
client.SetCookies(resp.Cookies())
resp, err = client.R().
SetBody(reqBody).
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
Post(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
So(err, ShouldBeNil)
// should work with session
resp, err = client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
Get(baseURL + constants.FullMgmtPrefix)
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).
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().
SetBasicAuth(email, apiKeyResponse.APIKey).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
So(err, ShouldBeNil)
// delete api key
resp, err = client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
SetQueryParam("id", apiKeyResponse.UUID).
Delete(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
Delete(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
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().
SetBasicAuth("test", "test").
SetQueryParam("id", apiKeyResponse.UUID).
Delete(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// unsupported method
resp, err = client.R().
Put(baseURL + constants.FullAPIKeyPrefix)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed)
})
})
}
func TestAPIKeysOpenDBError(t *testing.T) {
Convey("Test API keys - unable to create database", t, func() {
conf := config.New()
htpasswdPath := test.MakeHtpasswdFile()
defer os.Remove(htpasswdPath)
mockOIDCServer, err := test.MockOIDCRun()
if err != nil {
panic(err)
}
defer func() {
err := mockOIDCServer.Shutdown()
if err != nil {
panic(err)
}
}()
mockOIDCConfig := mockOIDCServer.Config()
conf.HTTP.Auth = &config.AuthConfig{
HTPasswd: config.AuthHTPasswd{
Path: htpasswdPath,
},
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{
"dex": {
ClientID: mockOIDCConfig.ClientID,
ClientSecret: mockOIDCConfig.ClientSecret,
KeyPath: "",
Issuer: mockOIDCConfig.Issuer,
Scopes: []string{"openid", "email"},
},
},
},
}
defaultVal := true
apiKeyConfig := &extconf.APIKeyConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
}
conf.Extensions = &extconf.ExtensionConfig{
APIKey: apiKeyConfig,
}
ctlr := api.NewController(conf)
dir := t.TempDir()
err = os.Chmod(dir, 0o000)
So(err, ShouldBeNil)
ctlr.Config.Storage.RootDirectory = dir
cm := test.NewControllerManager(ctlr)
So(func() {
cm.StartServer()
}, ShouldPanic)
})
}
+14 -1
View File
@@ -36,12 +36,19 @@ type BearerConfig struct {
Service string `json:"service,omitempty"`
}
type OpenIDProviderConfig struct{}
type OpenIDConfig struct {
Providers map[string]OpenIDProviderConfig `json:"providers,omitempty" mapstructure:"providers"`
}
type Auth struct {
HTPasswd *HTPasswd `json:"htpasswd,omitempty" mapstructure:"htpasswd"`
Bearer *BearerConfig `json:"bearer,omitempty" mapstructure:"bearer"`
LDAP *struct {
Address string `json:"address,omitempty" mapstructure:"address"`
} `json:"ldap,omitempty" mapstructure:"ldap"`
OpenID *OpenIDConfig `json:"openid,omitempty" mapstructure:"openid"`
}
type StrippedConfig struct {
@@ -60,8 +67,10 @@ func (auth Auth) MarshalJSON() ([]byte, error) {
type localAuth Auth
if auth.Bearer == nil && auth.LDAP == nil &&
auth.HTPasswd.Path == "" {
auth.HTPasswd.Path == "" &&
(auth.OpenID == nil || len(auth.OpenID.Providers) == 0) {
auth.HTPasswd = nil
auth.OpenID = nil
return json.Marshal((localAuth)(auth))
}
@@ -72,6 +81,10 @@ func (auth Auth) MarshalJSON() ([]byte, error) {
auth.HTPasswd.Path = ""
}
if auth.OpenID != nil && len(auth.OpenID.Providers) == 0 {
auth.OpenID = nil
}
auth.LDAP = nil
return json.Marshal((localAuth)(auth))
+1
View File
@@ -39,6 +39,7 @@ func SetupUserPreferencesRoutes(config *config.Config, router *mux.Router, store
userprefsRouter := router.PathPrefix(constants.ExtUserPreferences).Subrouter()
userprefsRouter.Use(zcommon.ACHeadersHandler(allowedMethods...))
userprefsRouter.Use(zcommon.AddExtensionSecurityHeaders())
userprefsRouter.HandleFunc("", HandleUserPrefs(repoDB, log)).Methods(allowedMethods...)
}
}
+166 -24
View File
@@ -1,5 +1,5 @@
//go:build sync || metrics || mgmt
// +build sync metrics mgmt
//go:build sync || metrics || mgmt || apikey
// +build sync metrics mgmt apikey
package extensions_test
@@ -128,6 +128,20 @@ func TestMgmtExtension(t *testing.T) {
defaultValue := true
mockOIDCServer, err := test.MockOIDCRun()
if err != nil {
panic(err)
}
defer func() {
err := mockOIDCServer.Shutdown()
if err != nil {
panic(err)
}
}()
mockOIDCConfig := mockOIDCServer.Config()
Convey("Verify mgmt route enabled with htpasswd", t, func() {
htpasswdPath := test.MakeHtpasswdFile()
conf.HTTP.Auth.HTPasswd.Path = htpasswdPath
@@ -145,7 +159,7 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
subPaths["/a"] = config.StorageConfig{RootDirectory: t.TempDir()}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.SubPaths = subPaths
@@ -158,6 +172,13 @@ func TestMgmtExtension(t *testing.T) {
So(string(data), ShouldContainSubstring, "setting up mgmt routes")
Convey("unsupported http method call", func() {
// without credentials
resp, err := resty.R().Patch(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed)
})
// without credentials
resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
@@ -210,9 +231,9 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
subPaths["/a"] = config.StorageConfig{RootDirectory: t.TempDir()}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlr.Config.Storage.SubPaths = subPaths
ctlrManager := test.NewControllerManager(ctlr)
@@ -259,9 +280,9 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
subPaths["/a"] = config.StorageConfig{RootDirectory: t.TempDir()}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlr.Config.Storage.SubPaths = subPaths
ctlrManager := test.NewControllerManager(ctlr)
@@ -325,11 +346,7 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.SubPaths = subPaths
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
@@ -396,9 +413,9 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
subPaths["/a"] = config.StorageConfig{RootDirectory: t.TempDir()}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlr.Config.Storage.SubPaths = subPaths
ctlrManager := test.NewControllerManager(ctlr)
@@ -445,11 +462,7 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.SubPaths = subPaths
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
@@ -474,6 +487,110 @@ func TestMgmtExtension(t *testing.T) {
So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service")
})
Convey("Verify mgmt route enabled with openID", t, func() {
conf.HTTP.Auth.HTPasswd.Path = ""
conf.HTTP.Auth.LDAP = nil
conf.HTTP.Auth.Bearer = nil
openIDProviders := make(map[string]config.OpenIDProviderConfig)
openIDProviders["dex"] = config.OpenIDProviderConfig{
ClientID: mockOIDCConfig.ClientID,
ClientSecret: mockOIDCConfig.ClientSecret,
Issuer: mockOIDCConfig.Issuer,
}
conf.HTTP.Auth.OpenID = &config.OpenIDConfig{
Providers: openIDProviders,
}
conf.Extensions = &extconf.ExtensionConfig{}
conf.Extensions.Mgmt = &extconf.MgmtConfig{
BaseConfig: extconf.BaseConfig{
Enable: &defaultValue,
},
}
conf.Log.Output = logFile.Name()
defer os.Remove(logFile.Name()) // cleanup
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
defer ctlrManager.StopServer()
data, _ := os.ReadFile(logFile.Name())
So(string(data), ShouldContainSubstring, "setting up mgmt routes")
// without credentials
resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
mgmtResp := extensions.StrippedConfig{}
err = json.Unmarshal(resp.Body(), &mgmtResp)
t.Logf("resp: %v", mgmtResp.HTTP.Auth.OpenID)
So(err, ShouldBeNil)
So(mgmtResp.HTTP.Auth.HTPasswd, ShouldBeNil)
So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil)
So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil)
So(mgmtResp.HTTP.Auth.OpenID, ShouldNotBeNil)
So(mgmtResp.HTTP.Auth.OpenID.Providers, ShouldNotBeEmpty)
})
Convey("Verify mgmt route enabled with empty openID provider list", t, func() {
htpasswdPath := test.MakeHtpasswdFile()
conf.HTTP.Auth.HTPasswd.Path = htpasswdPath
conf.HTTP.Auth.LDAP = nil
conf.HTTP.Auth.Bearer = nil
openIDProviders := make(map[string]config.OpenIDProviderConfig)
conf.HTTP.Auth.OpenID = &config.OpenIDConfig{
Providers: openIDProviders,
}
conf.Extensions = &extconf.ExtensionConfig{}
conf.Extensions.Mgmt = &extconf.MgmtConfig{
BaseConfig: extconf.BaseConfig{
Enable: &defaultValue,
},
}
conf.Log.Output = logFile.Name()
defer os.Remove(logFile.Name()) // cleanup
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
defer ctlrManager.StopServer()
data, _ := os.ReadFile(logFile.Name())
So(string(data), ShouldContainSubstring, "setting up mgmt routes")
// without credentials
resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
mgmtResp := extensions.StrippedConfig{}
err = json.Unmarshal(resp.Body(), &mgmtResp)
t.Logf("resp: %v", mgmtResp.HTTP.Auth.OpenID)
So(err, ShouldBeNil)
So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil)
So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil)
So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil)
So(mgmtResp.HTTP.Auth.OpenID, ShouldBeNil)
})
Convey("Verify mgmt route enabled without any auth", t, func() {
globalDir := t.TempDir()
conf := config.New()
@@ -499,11 +616,7 @@ func TestMgmtExtension(t *testing.T) {
ctlr := api.NewController(conf)
subPaths := make(map[string]config.StorageConfig)
subPaths["/a"] = config.StorageConfig{}
ctlr.Config.Storage.RootDirectory = globalDir
ctlr.Config.Storage.SubPaths = subPaths
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
@@ -856,3 +969,32 @@ func TestAllowedMethodsHeaderMgmt(t *testing.T) {
So(resp.StatusCode(), ShouldEqual, http.StatusNoContent)
})
}
func TestAllowedMethodsHeaderAPIKey(t *testing.T) {
defaultVal := true
Convey("Test http options response", t, func() {
conf := config.New()
port := test.GetFreePort()
conf.HTTP.Port = port
conf.Extensions = &extconf.ExtensionConfig{
APIKey: &extconf.APIKeyConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
},
}
baseURL := test.GetBaseURL(port)
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = t.TempDir()
ctrlManager := test.NewControllerManager(ctlr)
ctrlManager.StartAndWait(port)
defer ctrlManager.StopServer()
resp, _ := resty.R().Options(baseURL + constants.FullAPIKeyPrefix)
So(resp, ShouldNotBeNil)
So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "POST,DELETE,OPTIONS")
So(resp.StatusCode(), ShouldEqual, http.StatusNoContent)
})
}