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