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))
|
panic(fmt.Sprintf("unable to configure authorization (%s): %v", authType, err))
|
||||||
}
|
}
|
||||||
app.accessController = accessController
|
app.accessController = accessController
|
||||||
|
ctxu.GetLogger(app).Debugf("configured %q access controller", authType)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
Loading…
Reference in a new issue