1// Package htpasswd provides a simple authentication scheme that checks for the 2// user credential hash in an htpasswd formatted file in a configuration-determined 3// location. 4// 5// This authentication method MUST be used under TLS, as simple token-replay attack is possible. 6package htpasswd 7 8import ( 9 "context" 10 "crypto/rand" 11 "encoding/base64" 12 "fmt" 13 "net/http" 14 "os" 15 "path/filepath" 16 "sync" 17 "time" 18 19 "golang.org/x/crypto/bcrypt" 20 21 dcontext "github.com/docker/distribution/context" 22 "github.com/docker/distribution/registry/auth" 23) 24 25type accessController struct { 26 realm string 27 path string 28 modtime time.Time 29 mu sync.Mutex 30 htpasswd *htpasswd 31} 32 33var _ auth.AccessController = &accessController{} 34 35func newAccessController(options map[string]interface{}) (auth.AccessController, error) { 36 realm, present := options["realm"] 37 if _, ok := realm.(string); !present || !ok { 38 return nil, fmt.Errorf(`"realm" must be set for htpasswd access controller`) 39 } 40 41 pathOpt, present := options["path"] 42 path, ok := pathOpt.(string) 43 if !present || !ok { 44 return nil, fmt.Errorf(`"path" must be set for htpasswd access controller`) 45 } 46 if err := createHtpasswdFile(path); err != nil { 47 return nil, err 48 } 49 return &accessController{realm: realm.(string), path: path}, nil 50} 51 52func (ac *accessController) Authorized(ctx context.Context, accessRecords ...auth.Access) (context.Context, error) { 53 req, err := dcontext.GetRequest(ctx) 54 if err != nil { 55 return nil, err 56 } 57 58 username, password, ok := req.BasicAuth() 59 if !ok { 60 return nil, &challenge{ 61 realm: ac.realm, 62 err: auth.ErrInvalidCredential, 63 } 64 } 65 66 // Dynamically parsing the latest account list 67 fstat, err := os.Stat(ac.path) 68 if err != nil { 69 return nil, err 70 } 71 72 lastModified := fstat.ModTime() 73 ac.mu.Lock() 74 if ac.htpasswd == nil || !ac.modtime.Equal(lastModified) { 75 ac.modtime = lastModified 76 77 f, err := os.Open(ac.path) 78 if err != nil { 79 ac.mu.Unlock() 80 return nil, err 81 } 82 defer f.Close() 83 84 h, err := newHTPasswd(f) 85 if err != nil { 86 ac.mu.Unlock() 87 return nil, err 88 } 89 ac.htpasswd = h 90 } 91 localHTPasswd := ac.htpasswd 92 ac.mu.Unlock() 93 94 if err := localHTPasswd.authenticateUser(username, password); err != nil { 95 dcontext.GetLogger(ctx).Errorf("error authenticating user %q: %v", username, err) 96 return nil, &challenge{ 97 realm: ac.realm, 98 err: auth.ErrAuthenticationFailure, 99 } 100 } 101 102 return auth.WithUser(ctx, auth.UserInfo{Name: username}), nil 103} 104 105// challenge implements the auth.Challenge interface. 106type challenge struct { 107 realm string 108 err error 109} 110 111var _ auth.Challenge = challenge{} 112 113// SetHeaders sets the basic challenge header on the response. 114func (ch challenge) SetHeaders(r *http.Request, w http.ResponseWriter) { 115 w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", ch.realm)) 116} 117 118func (ch challenge) Error() string { 119 return fmt.Sprintf("basic authentication challenge for realm %q: %s", ch.realm, ch.err) 120} 121 122// createHtpasswdFile creates and populates htpasswd file with a new user in case the file is missing 123func createHtpasswdFile(path string) error { 124 if f, err := os.Open(path); err == nil { 125 f.Close() 126 return nil 127 } else if !os.IsNotExist(err) { 128 return err 129 } 130 131 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { 132 return err 133 } 134 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600) 135 if err != nil { 136 return fmt.Errorf("failed to open htpasswd path %s", err) 137 } 138 defer f.Close() 139 var secretBytes [32]byte 140 if _, err := rand.Read(secretBytes[:]); err != nil { 141 return err 142 } 143 pass := base64.RawURLEncoding.EncodeToString(secretBytes[:]) 144 encryptedPass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) 145 if err != nil { 146 return err 147 } 148 if _, err := f.Write([]byte(fmt.Sprintf("docker:%s", string(encryptedPass[:])))); err != nil { 149 return err 150 } 151 dcontext.GetLoggerWithFields(context.Background(), map[interface{}]interface{}{ 152 "user": "docker", 153 "password": pass, 154 }).Warnf("htpasswd is missing, provisioning with default user") 155 return nil 156} 157 158func init() { 159 auth.Register("htpasswd", auth.InitFunc(newAccessController)) 160} 161