Skip to content

Commit

Permalink
clair: add authorization checking
Browse files Browse the repository at this point in the history
This commit adds authorization via two methods: PSK and a Quay
keyserver.

The PSK simply uses a key specified in the configuration file.

The keyserver authorization checking relies on the extra "kid" JWT
header to look up a public key and validate the claims on the request.

The tests for the keyserver integration are opportunistic and require
passing a "keyserver" flag to the cmd/clair test for initial setup. Once
initial setup is done, the key and server information will be cached and
used opportunistically on future test runs.
  • Loading branch information
hdonnay committed Feb 27, 2020
1 parent 1b41336 commit 8039e1c
Show file tree
Hide file tree
Showing 12 changed files with 786 additions and 4 deletions.
48 changes: 48 additions & 0 deletions cmd/clair/httpauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"context"
"net/http"
"strings"
)

// AuthCheck is an interface that reports whether the passed request should be
// allowed to continue.
type AuthCheck interface {
Check(context.Context, *http.Request) bool
}

type authHandler struct {
auth AuthCheck
next http.Handler
}

func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !h.auth.Check(r.Context(), r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.next.ServeHTTP(w, r)
}

// AuthHandler returns a Handler that gates access to the passed Handler behind
// the passed AuthCheck.
func AuthHandler(h http.Handler, f AuthCheck) http.Handler {
return &authHandler{
auth: f,
next: h,
}
}

func fromHeader(r *http.Request) (string, bool) {
hs, ok := r.Header["Authorization"]
if !ok {
return "", false
}
for _, h := range hs {
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer "), true
}
}
return "", false
}
135 changes: 135 additions & 0 deletions cmd/clair/httpauth_keyserver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package main

import (
"context"
"encoding/json"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"

"github.com/gregjones/httpcache"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)

type ks struct {
root *url.URL
client *http.Client
mu sync.RWMutex
cache map[string]*jose.JSONWebKey
}

// Check implements AuthCheck.
func (s *ks) Check(ctx context.Context, r *http.Request) bool {
wt, ok := fromHeader(r)
if !ok {
return false
}
tok, err := jwt.ParseSigned(wt)
if err != nil {
return false
}
aud, err := r.URL.Parse("/")
if err != nil {
return false
}
// Need to find the key id.
ok = false
var kid string
for _, h := range tok.Headers {
if h.Algorithm == string(jose.RS256) {
ok = true
kid = h.KeyID
break
}
}
if !ok {
return false
}
// Need to pull out the issuer to fetch the key. We cannot return "true"
// until *after* a safe Claims call succeeds.
cl := jwt.Claims{}
if err := tok.UnsafeClaimsWithoutVerification(&cl); err != nil {
return false
}
uri, err := s.root.Parse(path.Join("./", "services", cl.Issuer, "keys", kid))
if err != nil {
return false
}
ck := cl.Issuer + "+" + kid

// This request will be cached according to the cache-control headers.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
if err != nil {
return false
}
req.URL = uri
res, err := s.client.Do(req)
if res != nil {
defer res.Body.Close()
}
if err != nil {
return false
}
if res.StatusCode != http.StatusOK {
// If the keyserver returns a non-OK, we can't use the key: it doesn't
// exist or is expired or is not yet approved, so make sure to delete it
// from our cache. Delete is a no-op if we don't have the key.
s.mu.Lock()
delete(s.cache, ck)
s.mu.Unlock()
return false
}
s.mu.RLock()
jwk, ok := s.cache[ck]
s.mu.RUnlock()
// If not in our deserialized cache or our response has been served from the
// remote server, do the deserializtion and cache it.
if !ok || res.Header.Get(httpcache.XFromCache) != "" {
jwk = &jose.JSONWebKey{}
if err := json.NewDecoder(res.Body).Decode(jwk); err != nil {
return false
}
s.mu.Lock()
// Only store if we didn't get beaten by another request.
if _, ok := s.cache[ck]; !ok {
s.cache[ck] = jwk
}
s.mu.Unlock()
}

if err := tok.Claims(jwk.Key, &cl); err != nil {
return false
}
// Returning true is now possible.
if err := cl.ValidateWithLeeway(jwt.Expected{
Audience: jwt.Audience{strings.TrimRight(aud.String(), "/")},
Time: time.Now(),
}, 15*time.Second); err != nil {
return false
}
return true
}

// QuayKeyserver returns an AuthCheck that validates JWTs by fetching keys from the
// Quay at "api".
//
// It follows the algorithm outlined here:
// https://github.com/quay/jwtproxy/tree/master/jwt/keyserver/keyregistry#verifier
func QuayKeyserver(api string) (AuthCheck, error) {
root, err := url.Parse(api)
if err != nil {
return nil, err
}

t := httpcache.NewMemoryCacheTransport()
t.MarkCachedResponses = true
return &ks{
client: t.Client(),
root: root,
cache: make(map[string]*jose.JSONWebKey),
}, nil
}
Loading

0 comments on commit 8039e1c

Please sign in to comment.