Merge pull request #608 from dmcgowan/http-basic-auth
Implementation of a basic authentication scheme using standard .htpasswd
This commit is contained in:
commit
fa67bab1c7
5 changed files with 389 additions and 0 deletions
102
docs/auth/htpasswd/access.go
Normal file
102
docs/auth/htpasswd/access.go
Normal file
|
@ -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))
|
||||
}
|
121
docs/auth/htpasswd/access_test.go
Normal file
121
docs/auth/htpasswd/access_test.go
Normal file
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
80
docs/auth/htpasswd/htpasswd.go
Normal file
80
docs/auth/htpasswd/htpasswd.go
Normal file
|
@ -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
|
||||
}
|
85
docs/auth/htpasswd/htpasswd_test.go
Normal file
85
docs/auth/htpasswd/htpasswd_test.go
Normal file
|
@ -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 a new issue