Added support for bcrypt, plaintext; extension points for other htpasswd hash methods.
Signed-off-by: Dave Trombley <dave.trombley@gmail.com>
This commit is contained in:
parent
d2b7988b7f
commit
ff67393b2b
3 changed files with 123 additions and 58 deletions
|
@ -9,11 +9,9 @@
|
||||||
package basic
|
package basic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
ctxu "github.com/docker/distribution/context"
|
ctxu "github.com/docker/distribution/context"
|
||||||
"github.com/docker/distribution/registry/auth"
|
"github.com/docker/distribution/registry/auth"
|
||||||
|
@ -59,7 +57,6 @@ func (ac *accessController) Authorized(ctx context.Context, accessRecords ...aut
|
||||||
}
|
}
|
||||||
|
|
||||||
authHeader := req.Header.Get("Authorization")
|
authHeader := req.Header.Get("Authorization")
|
||||||
|
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
challenge := challenge{
|
challenge := challenge{
|
||||||
realm: ac.realm,
|
realm: ac.realm,
|
||||||
|
@ -67,35 +64,20 @@ func (ac *accessController) Authorized(ctx context.Context, accessRecords ...aut
|
||||||
return nil, &challenge
|
return nil, &challenge
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(req.Header.Get("Authorization"), " ")
|
user, pass, ok := req.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("Invalid Authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, _ := ac.htpasswd.AuthenticateUser(user, pass); !res {
|
||||||
challenge := challenge{
|
challenge := challenge{
|
||||||
realm: ac.realm,
|
realm: ac.realm,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "basic" {
|
|
||||||
challenge.err = ErrPasswordRequired
|
|
||||||
return nil, &challenge
|
|
||||||
}
|
|
||||||
|
|
||||||
text, err := base64.StdEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
challenge.err = ErrInvalidCredential
|
challenge.err = ErrInvalidCredential
|
||||||
return nil, &challenge
|
return nil, &challenge
|
||||||
}
|
}
|
||||||
|
|
||||||
credential := strings.Split(string(text), ":")
|
return auth.WithUser(ctx, auth.UserInfo{Name: user}), nil
|
||||||
if len(credential) != 2 {
|
|
||||||
challenge.err = ErrInvalidCredential
|
|
||||||
return nil, &challenge
|
|
||||||
}
|
|
||||||
|
|
||||||
if res, _ := ac.htpasswd.AuthenticateUser(credential[0], credential[1]); !res {
|
|
||||||
challenge.err = ErrInvalidCredential
|
|
||||||
return nil, &challenge
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth.WithUser(ctx, auth.UserInfo{Name: credential[0]}), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ch *challenge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (ch *challenge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -14,8 +14,13 @@ import (
|
||||||
func TestBasicAccessController(t *testing.T) {
|
func TestBasicAccessController(t *testing.T) {
|
||||||
|
|
||||||
testRealm := "The-Shire"
|
testRealm := "The-Shire"
|
||||||
testUser := "bilbo"
|
testUsers := []string{"bilbo","frodo","MiShil","DeokMan"}
|
||||||
testHtpasswdContent := "bilbo:{SHA}5siv5c0SHx681xU6GiSx9ZQryqs="
|
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")
|
tempFile, err := ioutil.TempFile("", "htpasswd-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -37,6 +42,8 @@ func TestBasicAccessController(t *testing.T) {
|
||||||
|
|
||||||
tempFile.Close()
|
tempFile.Close()
|
||||||
|
|
||||||
|
var userNumber = 0
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := context.WithValue(nil, "http.request", r)
|
ctx := context.WithValue(nil, "http.request", r)
|
||||||
authCtx, err := accessController.Authorized(ctx)
|
authCtx, err := accessController.Authorized(ctx)
|
||||||
|
@ -55,8 +62,8 @@ func TestBasicAccessController(t *testing.T) {
|
||||||
t.Fatal("basic accessController did not set auth.user context")
|
t.Fatal("basic accessController did not set auth.user context")
|
||||||
}
|
}
|
||||||
|
|
||||||
if userInfo.Name != testUser {
|
if userInfo.Name != testUsers[userNumber] {
|
||||||
t.Fatalf("expected user name %q, got %q", testUser, userInfo.Name)
|
t.Fatalf("expected user name %q, got %q", testUsers[userNumber], userInfo.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
@ -79,9 +86,10 @@ func TestBasicAccessController(t *testing.T) {
|
||||||
t.Fatalf("unexpected non-fail response status: %v != %v", resp.StatusCode, http.StatusUnauthorized)
|
t.Fatalf("unexpected non-fail response status: %v != %v", resp.StatusCode, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(testUsers); i++ {
|
||||||
|
userNumber = i
|
||||||
req, _ = http.NewRequest("GET", server.URL, nil)
|
req, _ = http.NewRequest("GET", server.URL, nil)
|
||||||
|
sekrit := testUsers[i]+":"+testPasswords[i]
|
||||||
sekrit := "bilbo:baggins"
|
|
||||||
credential := "Basic " + base64.StdEncoding.EncodeToString([]byte(sekrit))
|
credential := "Basic " + base64.StdEncoding.EncodeToString([]byte(sekrit))
|
||||||
|
|
||||||
req.Header.Set("Authorization", credential)
|
req.Header.Set("Authorization", credential)
|
||||||
|
@ -94,7 +102,9 @@ func TestBasicAccessController(t *testing.T) {
|
||||||
|
|
||||||
// Request should be authorized
|
// Request should be authorized
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
t.Fatalf("unexpected non-success response status: %v != %v", resp.StatusCode, http.StatusNoContent)
|
t.Fatalf("unexpected non-success response status: %v != %v for %s %s %s", resp.StatusCode, http.StatusNoContent, testUsers[i], testPasswords[i], credential)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,14 @@ import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrSHARequired - returned in error field of challenge when the htpasswd was not made using SHA1 algorithm.
|
// AuthenticationFailureErr - a generic error message for authentication failure to be presented to agent.
|
||||||
// (SHA1 is considered obsolete but the alternative for htpasswd is MD5, or system crypt...)
|
var AuthenticationFailureErr = errors.New("Bad username or password")
|
||||||
var ErrSHARequired = errors.New("htpasswd file must use SHA (htpasswd -s)")
|
|
||||||
|
|
||||||
// HTPasswd - holds a path to a system .htpasswd file and the machinery to parse it.
|
// HTPasswd - holds a path to a system .htpasswd file and the machinery to parse it.
|
||||||
type HTPasswd struct {
|
type HTPasswd struct {
|
||||||
|
@ -18,18 +21,57 @@ type HTPasswd struct {
|
||||||
reader *csv.Reader
|
reader *csv.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthType represents a particular hash function used in the htpasswd file.
|
||||||
|
type AuthType int
|
||||||
|
const (
|
||||||
|
PlainText AuthType = iota
|
||||||
|
SHA1
|
||||||
|
ApacheMD5
|
||||||
|
BCrypt
|
||||||
|
Crypt
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a text representation of the AuthType
|
||||||
|
func (at AuthType) String() string {
|
||||||
|
switch(at) {
|
||||||
|
case PlainText: return "plaintext"
|
||||||
|
case SHA1: return "sha1"
|
||||||
|
case ApacheMD5: return "md5"
|
||||||
|
case BCrypt: return "bcrypt"
|
||||||
|
case Crypt: return "system crypt"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// NewHTPasswd - Create a new HTPasswd with the given path to .htpasswd file.
|
// NewHTPasswd - Create a new HTPasswd with the given path to .htpasswd file.
|
||||||
func NewHTPasswd(htpath string) *HTPasswd {
|
func NewHTPasswd(htpath string) *HTPasswd {
|
||||||
return &HTPasswd{path: htpath}
|
return &HTPasswd{path: htpath}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bcryptPrefixRegexp *regexp.Regexp = regexp.MustCompile(`^\$2[ab]?y\$`)
|
||||||
|
|
||||||
|
// GetAuthCredentialType - Inspect an htpasswd file credential and guess the encryption algorithm used.
|
||||||
|
func GetAuthCredentialType(cred string) AuthType {
|
||||||
|
if strings.HasPrefix(cred, "{SHA}") {
|
||||||
|
return SHA1
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cred, "$apr1$") {
|
||||||
|
return ApacheMD5
|
||||||
|
}
|
||||||
|
if bcryptPrefixRegexp.MatchString(cred) {
|
||||||
|
return BCrypt
|
||||||
|
}
|
||||||
|
// There's just not a great way to distinguish between these next two...
|
||||||
|
if len(cred) == 13 {
|
||||||
|
return Crypt
|
||||||
|
}
|
||||||
|
return PlainText
|
||||||
|
}
|
||||||
|
|
||||||
// AuthenticateUser - Check a given user:password credential against the receiving HTPasswd's file.
|
// AuthenticateUser - Check a given user:password credential against the receiving HTPasswd's file.
|
||||||
func (htpasswd *HTPasswd) AuthenticateUser(user string, pwd string) (bool, error) {
|
func (htpasswd *HTPasswd) AuthenticateUser(user string, pwd string) (bool, error) {
|
||||||
|
|
||||||
// Hash the credential.
|
|
||||||
sha := sha1.New()
|
|
||||||
sha.Write([]byte(pwd))
|
|
||||||
hash := base64.StdEncoding.EncodeToString(sha.Sum(nil))
|
|
||||||
|
|
||||||
// Open the file.
|
// Open the file.
|
||||||
in, err := os.Open(htpasswd.path)
|
in, err := os.Open(htpasswd.path)
|
||||||
|
@ -43,12 +85,43 @@ func (htpasswd *HTPasswd) AuthenticateUser(user string, pwd string) (bool, error
|
||||||
reader.Comment = '#'
|
reader.Comment = '#'
|
||||||
reader.TrimLeadingSpace = true
|
reader.TrimLeadingSpace = true
|
||||||
for entry, readerr := reader.Read(); entry != nil || readerr != nil; entry, readerr = reader.Read() {
|
for entry, readerr := reader.Read(); entry != nil || readerr != nil; entry, readerr = reader.Read() {
|
||||||
if entry[0] == user {
|
if readerr != nil {
|
||||||
if len(entry[1]) < 6 || entry[1][0:5] != "{SHA}" {
|
return false, readerr
|
||||||
return false, ErrSHARequired
|
|
||||||
}
|
}
|
||||||
|
if len(entry) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry[0] == user {
|
||||||
|
credential := entry[1]
|
||||||
|
credType := GetAuthCredentialType(credential)
|
||||||
|
switch(credType) {
|
||||||
|
case SHA1: {
|
||||||
|
sha := sha1.New()
|
||||||
|
sha.Write([]byte(pwd))
|
||||||
|
hash := base64.StdEncoding.EncodeToString(sha.Sum(nil))
|
||||||
return entry[1][5:] == hash, nil
|
return entry[1][5:] == hash, nil
|
||||||
}
|
}
|
||||||
|
case ApacheMD5: {
|
||||||
|
return false, errors.New(ApacheMD5.String()+" htpasswd hash function not yet supported")
|
||||||
}
|
}
|
||||||
return false, nil
|
case BCrypt: {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(credential),[]byte(pwd))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
case Crypt: {
|
||||||
|
return false, errors.New(Crypt.String()+" htpasswd hash function not yet supported")
|
||||||
|
}
|
||||||
|
case PlainText: {
|
||||||
|
if pwd == credential {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, AuthenticationFailureErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, AuthenticationFailureErr
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue