Merge pull request #608 from dmcgowan/http-basic-auth
Implementation of a basic authentication scheme using standard .htpasswdmaster
						commit
						fa67bab1c7
					
				| 
						 | 
				
			
			@ -0,0 +1,102 @@
 | 
			
		|||
// Package htpasswd provides a simple authentication scheme that checks for the
 | 
			
		||||
// user credential hash in an htpasswd formatted file in a configuration-determined
 | 
			
		||||
// location.
 | 
			
		||||
//
 | 
			
		||||
// This authentication method MUST be used under TLS, as simple token-replay attack is possible.
 | 
			
		||||
package htpasswd
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	ctxu "github.com/docker/distribution/context"
 | 
			
		||||
	"github.com/docker/distribution/registry/auth"
 | 
			
		||||
	"golang.org/x/net/context"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	// ErrInvalidCredential is returned when the auth token does not authenticate correctly.
 | 
			
		||||
	ErrInvalidCredential = errors.New("invalid authorization credential")
 | 
			
		||||
 | 
			
		||||
	// ErrAuthenticationFailure returned when authentication failure to be presented to agent.
 | 
			
		||||
	ErrAuthenticationFailure = errors.New("authentication failured")
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type accessController struct {
 | 
			
		||||
	realm    string
 | 
			
		||||
	htpasswd *htpasswd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var _ auth.AccessController = &accessController{}
 | 
			
		||||
 | 
			
		||||
func newAccessController(options map[string]interface{}) (auth.AccessController, error) {
 | 
			
		||||
	realm, present := options["realm"]
 | 
			
		||||
	if _, ok := realm.(string); !present || !ok {
 | 
			
		||||
		return nil, fmt.Errorf(`"realm" must be set for htpasswd access controller`)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	path, present := options["path"]
 | 
			
		||||
	if _, ok := path.(string); !present || !ok {
 | 
			
		||||
		return nil, fmt.Errorf(`"path" must be set for htpasswd access controller`)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	f, err := os.Open(path.(string))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer f.Close()
 | 
			
		||||
 | 
			
		||||
	h, err := newHTPasswd(f)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &accessController{realm: realm.(string), htpasswd: h}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (ac *accessController) Authorized(ctx context.Context, accessRecords ...auth.Access) (context.Context, error) {
 | 
			
		||||
	req, err := ctxu.GetRequest(ctx)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	username, password, ok := req.BasicAuth()
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, &challenge{
 | 
			
		||||
			realm: ac.realm,
 | 
			
		||||
			err:   ErrInvalidCredential,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := ac.htpasswd.authenticateUser(username, password); err != nil {
 | 
			
		||||
		ctxu.GetLogger(ctx).Errorf("error authenticating user %q: %v", username, err)
 | 
			
		||||
		return nil, &challenge{
 | 
			
		||||
			realm: ac.realm,
 | 
			
		||||
			err:   ErrAuthenticationFailure,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return auth.WithUser(ctx, auth.UserInfo{Name: username}), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// challenge implements the auth.Challenge interface.
 | 
			
		||||
type challenge struct {
 | 
			
		||||
	realm string
 | 
			
		||||
	err   error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (ch *challenge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	header := fmt.Sprintf("Basic realm=%q", ch.realm)
 | 
			
		||||
	w.Header().Set("WWW-Authenticate", header)
 | 
			
		||||
	w.WriteHeader(http.StatusUnauthorized)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (ch *challenge) Error() string {
 | 
			
		||||
	return fmt.Sprintf("basic authentication challenge: %#v", ch)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	auth.Register("htpasswd", auth.InitFunc(newAccessController))
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,121 @@
 | 
			
		|||
package htpasswd
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/http/httptest"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/docker/distribution/context"
 | 
			
		||||
	"github.com/docker/distribution/registry/auth"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestBasicAccessController(t *testing.T) {
 | 
			
		||||
	testRealm := "The-Shire"
 | 
			
		||||
	testUsers := []string{"bilbo", "frodo", "MiShil", "DeokMan"}
 | 
			
		||||
	testPasswords := []string{"baggins", "baggins", "새주", "공주님"}
 | 
			
		||||
	testHtpasswdContent := `bilbo:{SHA}5siv5c0SHx681xU6GiSx9ZQryqs=
 | 
			
		||||
							frodo:$2y$05$926C3y10Quzn/LnqQH86VOEVh/18T6RnLaS.khre96jLNL/7e.K5W
 | 
			
		||||
							MiShil:$2y$05$0oHgwMehvoe8iAWS8I.7l.KoECXrwVaC16RPfaSCU5eVTFrATuMI2
 | 
			
		||||
							DeokMan:공주님`
 | 
			
		||||
 | 
			
		||||
	tempFile, err := ioutil.TempFile("", "htpasswd-test")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal("could not create temporary htpasswd file")
 | 
			
		||||
	}
 | 
			
		||||
	if _, err = tempFile.WriteString(testHtpasswdContent); err != nil {
 | 
			
		||||
		t.Fatal("could not write temporary htpasswd file")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	options := map[string]interface{}{
 | 
			
		||||
		"realm": testRealm,
 | 
			
		||||
		"path":  tempFile.Name(),
 | 
			
		||||
	}
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
 | 
			
		||||
	accessController, err := newAccessController(options)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal("error creating access controller")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tempFile.Close()
 | 
			
		||||
 | 
			
		||||
	var userNumber = 0
 | 
			
		||||
 | 
			
		||||
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		ctx := context.WithRequest(ctx, r)
 | 
			
		||||
		authCtx, err := accessController.Authorized(ctx)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			switch err := err.(type) {
 | 
			
		||||
			case auth.Challenge:
 | 
			
		||||
				err.ServeHTTP(w, r)
 | 
			
		||||
				return
 | 
			
		||||
			default:
 | 
			
		||||
				t.Fatalf("unexpected error authorizing request: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		userInfo, ok := authCtx.Value("auth.user").(auth.UserInfo)
 | 
			
		||||
		if !ok {
 | 
			
		||||
			t.Fatal("basic accessController did not set auth.user context")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if userInfo.Name != testUsers[userNumber] {
 | 
			
		||||
			t.Fatalf("expected user name %q, got %q", testUsers[userNumber], userInfo.Name)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		w.WriteHeader(http.StatusNoContent)
 | 
			
		||||
	}))
 | 
			
		||||
 | 
			
		||||
	client := &http.Client{
 | 
			
		||||
		CheckRedirect: nil,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	req, _ := http.NewRequest("GET", server.URL, nil)
 | 
			
		||||
	resp, err := client.Do(req)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("unexpected error during GET: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
	// Request should not be authorized
 | 
			
		||||
	if resp.StatusCode != http.StatusUnauthorized {
 | 
			
		||||
		t.Fatalf("unexpected non-fail response status: %v != %v", resp.StatusCode, http.StatusUnauthorized)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	nonbcrypt := map[string]struct{}{
 | 
			
		||||
		"bilbo":   {},
 | 
			
		||||
		"DeokMan": {},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i := 0; i < len(testUsers); i++ {
 | 
			
		||||
		userNumber = i
 | 
			
		||||
		req, err := http.NewRequest("GET", server.URL, nil)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("error allocating new request: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		req.SetBasicAuth(testUsers[i], testPasswords[i])
 | 
			
		||||
 | 
			
		||||
		resp, err = client.Do(req)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("unexpected error during GET: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
		if _, ok := nonbcrypt[testUsers[i]]; ok {
 | 
			
		||||
			// these are not allowed.
 | 
			
		||||
			// Request should be authorized
 | 
			
		||||
			if resp.StatusCode != http.StatusUnauthorized {
 | 
			
		||||
				t.Fatalf("unexpected non-success response status: %v != %v for %s %s", resp.StatusCode, http.StatusUnauthorized, testUsers[i], testPasswords[i])
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			// Request should be authorized
 | 
			
		||||
			if resp.StatusCode != http.StatusNoContent {
 | 
			
		||||
				t.Fatalf("unexpected non-success response status: %v != %v for %s %s", resp.StatusCode, http.StatusNoContent, testUsers[i], testPasswords[i])
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,80 @@
 | 
			
		|||
package htpasswd
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"golang.org/x/crypto/bcrypt"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// htpasswd holds a path to a system .htpasswd file and the machinery to parse
 | 
			
		||||
// it. Only bcrypt hash entries are supported.
 | 
			
		||||
type htpasswd struct {
 | 
			
		||||
	entries map[string][]byte // maps username to password byte slice.
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// newHTPasswd parses the reader and returns an htpasswd or an error.
 | 
			
		||||
func newHTPasswd(rd io.Reader) (*htpasswd, error) {
 | 
			
		||||
	entries, err := parseHTPasswd(rd)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &htpasswd{entries: entries}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// AuthenticateUser checks a given user:password credential against the
 | 
			
		||||
// receiving HTPasswd's file. If the check passes, nil is returned.
 | 
			
		||||
func (htpasswd *htpasswd) authenticateUser(username string, password string) error {
 | 
			
		||||
	credentials, ok := htpasswd.entries[username]
 | 
			
		||||
	if !ok {
 | 
			
		||||
		// timing attack paranoia
 | 
			
		||||
		bcrypt.CompareHashAndPassword([]byte{}, []byte(password))
 | 
			
		||||
 | 
			
		||||
		return ErrAuthenticationFailure
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err := bcrypt.CompareHashAndPassword([]byte(credentials), []byte(password))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrAuthenticationFailure
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// parseHTPasswd parses the contents of htpasswd. This will read all the
 | 
			
		||||
// entries in the file, whether or not they are needed. An error is returned
 | 
			
		||||
// if an syntax errors are encountered or if the reader fails.
 | 
			
		||||
func parseHTPasswd(rd io.Reader) (map[string][]byte, error) {
 | 
			
		||||
	entries := map[string][]byte{}
 | 
			
		||||
	scanner := bufio.NewScanner(rd)
 | 
			
		||||
	var line int
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		line++ // 1-based line numbering
 | 
			
		||||
		t := strings.TrimSpace(scanner.Text())
 | 
			
		||||
 | 
			
		||||
		if len(t) < 1 {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// lines that *begin* with a '#' are considered comments
 | 
			
		||||
		if t[0] == '#' {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		i := strings.Index(t, ":")
 | 
			
		||||
		if i < 0 || i >= len(t) {
 | 
			
		||||
			return nil, fmt.Errorf("htpasswd: invalid entry at line %d: %q", line, scanner.Text())
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		entries[t[:i]] = []byte(t[i+1:])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := scanner.Err(); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return entries, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,85 @@
 | 
			
		|||
package htpasswd
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"reflect"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestParseHTPasswd(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	for _, tc := range []struct {
 | 
			
		||||
		desc    string
 | 
			
		||||
		input   string
 | 
			
		||||
		err     error
 | 
			
		||||
		entries map[string][]byte
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			desc: "basic example",
 | 
			
		||||
			input: `
 | 
			
		||||
# This is a comment in a basic example.
 | 
			
		||||
bilbo:{SHA}5siv5c0SHx681xU6GiSx9ZQryqs=
 | 
			
		||||
frodo:$2y$05$926C3y10Quzn/LnqQH86VOEVh/18T6RnLaS.khre96jLNL/7e.K5W
 | 
			
		||||
MiShil:$2y$05$0oHgwMehvoe8iAWS8I.7l.KoECXrwVaC16RPfaSCU5eVTFrATuMI2
 | 
			
		||||
DeokMan:공주님
 | 
			
		||||
`,
 | 
			
		||||
			entries: map[string][]byte{
 | 
			
		||||
				"bilbo":   []byte("{SHA}5siv5c0SHx681xU6GiSx9ZQryqs="),
 | 
			
		||||
				"frodo":   []byte("$2y$05$926C3y10Quzn/LnqQH86VOEVh/18T6RnLaS.khre96jLNL/7e.K5W"),
 | 
			
		||||
				"MiShil":  []byte("$2y$05$0oHgwMehvoe8iAWS8I.7l.KoECXrwVaC16RPfaSCU5eVTFrATuMI2"),
 | 
			
		||||
				"DeokMan": []byte("공주님"),
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			desc: "ensures comments are filtered",
 | 
			
		||||
			input: `
 | 
			
		||||
# asdf:asdf
 | 
			
		||||
`,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			desc: "ensure midline hash is not comment",
 | 
			
		||||
			input: `
 | 
			
		||||
asdf:as#df
 | 
			
		||||
`,
 | 
			
		||||
			entries: map[string][]byte{
 | 
			
		||||
				"asdf": []byte("as#df"),
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			desc: "ensure midline hash is not comment",
 | 
			
		||||
			input: `
 | 
			
		||||
# A valid comment
 | 
			
		||||
valid:entry
 | 
			
		||||
asdf
 | 
			
		||||
`,
 | 
			
		||||
			err: fmt.Errorf(`htpasswd: invalid entry at line 4: "asdf"`),
 | 
			
		||||
		},
 | 
			
		||||
	} {
 | 
			
		||||
 | 
			
		||||
		entries, err := parseHTPasswd(strings.NewReader(tc.input))
 | 
			
		||||
		if err != tc.err {
 | 
			
		||||
			if tc.err == nil {
 | 
			
		||||
				t.Fatalf("%s: unexpected error: %v", tc.desc, err)
 | 
			
		||||
			} else {
 | 
			
		||||
				if err.Error() != tc.err.Error() { // use string equality here.
 | 
			
		||||
					t.Fatalf("%s: expected error not returned: %v != %v", tc.desc, err, tc.err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if tc.err != nil {
 | 
			
		||||
			continue // don't test output
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// allow empty and nil to be equal
 | 
			
		||||
		if tc.entries == nil {
 | 
			
		||||
			tc.entries = map[string][]byte{}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !reflect.DeepEqual(entries, tc.entries) {
 | 
			
		||||
			t.Fatalf("%s: entries not parsed correctly: %v != %v", tc.desc, entries, tc.entries)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -148,6 +148,7 @@ func NewApp(ctx context.Context, configuration configuration.Configuration) *App
 | 
			
		|||
			panic(fmt.Sprintf("unable to configure authorization (%s): %v", authType, err))
 | 
			
		||||
		}
 | 
			
		||||
		app.accessController = accessController
 | 
			
		||||
		ctxu.GetLogger(app).Debugf("configured %q access controller", authType)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return app
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue