mirror of
https://github.com/project-zot/zot.git
synced 2026-06-16 04:17:55 +08:00
Add support for bearer/token auth
New options added to configuration file to reference a public key used to validate authorization tokens signed by an auth server with corresponding private key. Resolves #24 Signed-off-by: Peter Engelbert <pmengelbert@gmail.com>
This commit is contained in:
@@ -18,12 +18,15 @@ go_library(
|
||||
"//errors:go_default_library",
|
||||
"//pkg/log:go_default_library",
|
||||
"//pkg/storage:go_default_library",
|
||||
"@com_github_chartmuseum_auth//:go_default_library",
|
||||
"@com_github_getlantern_deepcopy//:go_default_library",
|
||||
"@com_github_go_ldap_ldap_v3//:go_default_library",
|
||||
"@com_github_gorilla_handlers//:go_default_library",
|
||||
"@com_github_gorilla_mux//:go_default_library",
|
||||
"@com_github_json_iterator_go//:go_default_library",
|
||||
"@com_github_mitchellh_mapstructure//:go_default_library",
|
||||
"@com_github_opencontainers_distribution_spec//:go_default_library",
|
||||
"@com_github_opencontainers_go_digest//:go_default_library",
|
||||
"@com_github_opencontainers_image_spec//specs-go/v1:go_default_library",
|
||||
"@com_github_swaggo_http_swagger//:go_default_library",
|
||||
"@org_golang_x_crypto//bcrypt:go_default_library",
|
||||
|
||||
+61
-8
@@ -13,19 +13,65 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/anuvu/zot/errors"
|
||||
"github.com/chartmuseum/auth"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func authFail(w http.ResponseWriter, realm string, delay int) {
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
w.Header().Set("WWW-Authenticate", realm)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
WriteJSON(w, http.StatusUnauthorized, NewError(UNAUTHORIZED))
|
||||
const (
|
||||
bearerAuthDefaultAccessEntryType = "repository"
|
||||
)
|
||||
|
||||
func AuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
if c.Config.HTTP.Auth != nil &&
|
||||
c.Config.HTTP.Auth.Bearer != nil &&
|
||||
c.Config.HTTP.Auth.Bearer.Cert != "" &&
|
||||
c.Config.HTTP.Auth.Bearer.Realm != "" &&
|
||||
c.Config.HTTP.Auth.Bearer.Service != "" {
|
||||
return bearerAuthHandler(c)
|
||||
}
|
||||
|
||||
return basicAuthHandler(c)
|
||||
}
|
||||
|
||||
func bearerAuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
authorizer, err := auth.NewAuthorizer(&auth.AuthorizerOptions{
|
||||
Realm: c.Config.HTTP.Auth.Bearer.Realm,
|
||||
Service: c.Config.HTTP.Auth.Bearer.Service,
|
||||
PublicKeyPath: c.Config.HTTP.Auth.Bearer.Cert,
|
||||
AccessEntryType: bearerAuthDefaultAccessEntryType,
|
||||
})
|
||||
if err != nil {
|
||||
c.Log.Panic().Err(err).Msg("error creating bearer authorizer")
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
name := vars["name"]
|
||||
header := r.Header.Get("Authorization")
|
||||
action := auth.PullAction
|
||||
if m := r.Method; m != http.MethodGet && m != http.MethodHead {
|
||||
action = auth.PushAction
|
||||
}
|
||||
permissions, err := authorizer.Authorize(header, action, name)
|
||||
if err != nil {
|
||||
c.Log.Error().Err(err).Msg("issue parsing Authorization header")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
WriteJSON(w, http.StatusInternalServerError, NewError(UNSUPPORTED))
|
||||
return
|
||||
}
|
||||
if !permissions.Allowed {
|
||||
authFail(w, permissions.WWWAuthenticateHeader, 0)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// nolint (gocyclo) - we use closure making this a complex subroutine
|
||||
func BasicAuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
func basicAuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
realm := c.Config.HTTP.Realm
|
||||
if realm == "" {
|
||||
realm = "Authorization Required"
|
||||
@@ -39,7 +85,7 @@ func BasicAuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
if c.Config.HTTP.AllowReadAccess &&
|
||||
c.Config.HTTP.TLS.CACert != "" &&
|
||||
r.TLS.VerifiedChains == nil &&
|
||||
r.Method != "GET" && r.Method != "HEAD" {
|
||||
r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
authFail(w, realm, 5)
|
||||
return
|
||||
}
|
||||
@@ -109,7 +155,7 @@ func BasicAuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if (r.Method == "GET" || r.Method == "HEAD") && c.Config.HTTP.AllowReadAccess {
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && c.Config.HTTP.AllowReadAccess {
|
||||
// Process request
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -167,3 +213,10 @@ func BasicAuthHandler(c *Controller) mux.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authFail(w http.ResponseWriter, realm string, delay int) {
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
w.Header().Set("WWW-Authenticate", realm)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
WriteJSON(w, http.StatusUnauthorized, NewError(UNAUTHORIZED))
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ type AuthConfig struct {
|
||||
FailDelay int
|
||||
HTPasswd AuthHTPasswd
|
||||
LDAP *LDAPConfig
|
||||
Bearer *BearerConfig
|
||||
}
|
||||
|
||||
type BearerConfig struct {
|
||||
Realm string
|
||||
Service string
|
||||
Cert string
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
|
||||
+244
-10
@@ -9,27 +9,51 @@ import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anuvu/zot/pkg/api"
|
||||
"github.com/chartmuseum/auth"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
vldap "github.com/nmcclain/ldap"
|
||||
godigest "github.com/opencontainers/go-digest"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"gopkg.in/resty.v1"
|
||||
)
|
||||
|
||||
const (
|
||||
BaseURL1 = "http://127.0.0.1:8081"
|
||||
BaseURL2 = "http://127.0.0.1:8082"
|
||||
BaseSecureURL2 = "https://127.0.0.1:8082"
|
||||
SecurePort1 = "8081"
|
||||
SecurePort2 = "8082"
|
||||
username = "test"
|
||||
passphrase = "test"
|
||||
ServerCert = "../../test/data/server.cert"
|
||||
ServerKey = "../../test/data/server.key"
|
||||
CACert = "../../test/data/ca.crt"
|
||||
BaseURL1 = "http://127.0.0.1:8081"
|
||||
BaseURL2 = "http://127.0.0.1:8082"
|
||||
BaseURL3 = "http://127.0.0.1:8083"
|
||||
BaseSecureURL2 = "https://127.0.0.1:8082"
|
||||
SecurePort1 = "8081"
|
||||
SecurePort2 = "8082"
|
||||
SecurePort3 = "8083"
|
||||
username = "test"
|
||||
passphrase = "test"
|
||||
ServerCert = "../../test/data/server.cert"
|
||||
ServerKey = "../../test/data/server.key"
|
||||
CACert = "../../test/data/ca.crt"
|
||||
AuthorizedNamespace = "everyone/isallowed"
|
||||
UnauthorizedNamespace = "fortknox/notallowed"
|
||||
)
|
||||
|
||||
type (
|
||||
accessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
authHeader struct {
|
||||
Realm string
|
||||
Service string
|
||||
Scope string
|
||||
}
|
||||
)
|
||||
|
||||
func makeHtpasswdFile() string {
|
||||
@@ -782,3 +806,213 @@ func TestBasicAuthWithLDAP(t *testing.T) {
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBearerAuth(t *testing.T) {
|
||||
Convey("Make a new controller", t, func() {
|
||||
authTestServer := makeAuthTestServer()
|
||||
defer authTestServer.Close()
|
||||
|
||||
config := api.NewConfig()
|
||||
config.HTTP.Port = SecurePort3
|
||||
|
||||
u, err := url.Parse(authTestServer.URL)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
config.HTTP.Auth = &api.AuthConfig{
|
||||
Bearer: &api.BearerConfig{
|
||||
Cert: ServerCert,
|
||||
Realm: authTestServer.URL + "/auth/token",
|
||||
Service: u.Host,
|
||||
},
|
||||
}
|
||||
c := api.NewController(config)
|
||||
dir, err := ioutil.TempDir("", "oci-repo-test")
|
||||
So(err, ShouldBeNil)
|
||||
defer os.RemoveAll(dir)
|
||||
c.Config.Storage.RootDirectory = dir
|
||||
go func() {
|
||||
// this blocks
|
||||
if err := c.Run(); err != nil {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// wait till ready
|
||||
for {
|
||||
_, err := resty.R().Get(BaseURL3)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
ctx := context.Background()
|
||||
_ = c.Server.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
blob := []byte("hello, blob!")
|
||||
digest := godigest.FromBytes(blob).String()
|
||||
|
||||
resp, err := resty.R().Post(BaseURL3 + "/v2/" + AuthorizedNamespace + "/blobs/uploads/")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 401)
|
||||
|
||||
authorizationHeader := parseBearerAuthHeader(resp.Header().Get("Www-Authenticate"))
|
||||
resp, err = resty.R().
|
||||
SetQueryParam("service", authorizationHeader.Service).
|
||||
SetQueryParam("scope", authorizationHeader.Scope).
|
||||
Get(authorizationHeader.Realm)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
var goodToken accessTokenResponse
|
||||
err = json.Unmarshal(resp.Body(), &goodToken)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)).
|
||||
Post(BaseURL3 + "/v2/" + AuthorizedNamespace + "/blobs/uploads/")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 202)
|
||||
loc := resp.Header().Get("Location")
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))).
|
||||
SetHeader("Content-Type", "application/octet-stream").
|
||||
SetQueryParam("digest", digest).
|
||||
SetBody(blob).
|
||||
Put(BaseURL3 + loc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 401)
|
||||
|
||||
authorizationHeader = parseBearerAuthHeader(resp.Header().Get("Www-Authenticate"))
|
||||
resp, err = resty.R().
|
||||
SetQueryParam("service", authorizationHeader.Service).
|
||||
SetQueryParam("scope", authorizationHeader.Scope).
|
||||
Get(authorizationHeader.Realm)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
err = json.Unmarshal(resp.Body(), &goodToken)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))).
|
||||
SetHeader("Content-Type", "application/octet-stream").
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)).
|
||||
SetQueryParam("digest", digest).
|
||||
SetBody(blob).
|
||||
Put(BaseURL3 + loc)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 201)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)).
|
||||
Get(BaseURL3 + "/v2/" + AuthorizedNamespace + "/tags/list")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 401)
|
||||
|
||||
authorizationHeader = parseBearerAuthHeader(resp.Header().Get("Www-Authenticate"))
|
||||
resp, err = resty.R().
|
||||
SetQueryParam("service", authorizationHeader.Service).
|
||||
SetQueryParam("scope", authorizationHeader.Scope).
|
||||
Get(authorizationHeader.Realm)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
err = json.Unmarshal(resp.Body(), &goodToken)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)).
|
||||
Get(BaseURL3 + "/v2/" + AuthorizedNamespace + "/tags/list")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
|
||||
resp, err = resty.R().
|
||||
Post(BaseURL3 + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 401)
|
||||
|
||||
authorizationHeader = parseBearerAuthHeader(resp.Header().Get("Www-Authenticate"))
|
||||
resp, err = resty.R().
|
||||
SetQueryParam("service", authorizationHeader.Service).
|
||||
SetQueryParam("scope", authorizationHeader.Scope).
|
||||
Get(authorizationHeader.Realm)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
var badToken accessTokenResponse
|
||||
err = json.Unmarshal(resp.Body(), &badToken)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", badToken.AccessToken)).
|
||||
Post(BaseURL3 + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 401)
|
||||
})
|
||||
}
|
||||
|
||||
func makeAuthTestServer() *httptest.Server {
|
||||
cmTokenGenerator, err := auth.NewTokenGenerator(&auth.TokenGeneratorOptions{
|
||||
PrivateKeyPath: ServerKey,
|
||||
Audience: "Zot Registry",
|
||||
Issuer: "Zot",
|
||||
AddKIDHeader: true,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
authTestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.URL.Query().Get("scope")
|
||||
parts := strings.Split(scope, ":")
|
||||
name := parts[1]
|
||||
actions := strings.Split(parts[2], ",")
|
||||
if name == UnauthorizedNamespace {
|
||||
actions = []string{}
|
||||
}
|
||||
access := []auth.AccessEntry{
|
||||
{
|
||||
Name: name,
|
||||
Type: "repository",
|
||||
Actions: actions,
|
||||
},
|
||||
}
|
||||
token, err := cmTokenGenerator.GenerateToken(access, time.Minute*1)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"access_token": "%s"}`, token)
|
||||
}))
|
||||
|
||||
return authTestServer
|
||||
}
|
||||
|
||||
func parseBearerAuthHeader(authHeaderRaw string) *authHeader {
|
||||
re := regexp.MustCompile(`([a-zA-z]+)="(.+?)"`)
|
||||
matches := re.FindAllStringSubmatch(authHeaderRaw, -1)
|
||||
m := make(map[string]string)
|
||||
|
||||
for i := 0; i < len(matches); i++ {
|
||||
m[matches[i][1]] = matches[i][2]
|
||||
}
|
||||
|
||||
var h authHeader
|
||||
if err := mapstructure.Decode(m, &h); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &h
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ func NewRouteHandler(c *Controller) *RouteHandler {
|
||||
}
|
||||
|
||||
func (rh *RouteHandler) SetupRoutes() {
|
||||
rh.c.Router.Use(BasicAuthHandler(rh.c))
|
||||
rh.c.Router.Use(AuthHandler(rh.c))
|
||||
g := rh.c.Router.PathPrefix(RoutePrefix).Subrouter()
|
||||
{
|
||||
g.HandleFunc(fmt.Sprintf("/{name:%s}/tags/list", NameRegexp.String()),
|
||||
|
||||
Reference in New Issue
Block a user