Skip to content

Commit c987ffe

Browse files
committed
feat: add changing user password route for htpasswd
1 parent ee9bbb0 commit c987ffe

12 files changed

+755
-29
lines changed

errors/errors.go

+10
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,14 @@ var (
168168
ErrAPINotSupported = errors.New("registry at the given address doesn't implement the correct API")
169169
ErrURLNotFound = errors.New("url not found")
170170
ErrInvalidSearchQuery = errors.New("invalid search query")
171+
172+
// ErrUserIsNotFound returned if the user is not found.
173+
ErrUserIsNotFound = errors.New("user is not found")
174+
// ErrPasswordsDoNotMatch returned if given password does not match existing user's password.
175+
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
176+
// ErrOldPasswordIsWrong returned if provided old password for user verification
177+
// during the password change is wrong.
178+
ErrOldPasswordIsWrong = errors.New("old password is wrong")
179+
// ErrPasswordIsEmpty returned if user's new password is empty
180+
ErrPasswordIsEmpty = errors.New("password can not be empty")
171181
)

pkg/api/authn.go

+5-28
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package api
22

33
import (
4-
"bufio"
54
"context"
65
"crypto/sha256"
76
"crypto/x509"
@@ -26,7 +25,6 @@ import (
2625
"github.com/zitadel/oidc/pkg/client/rp"
2726
httphelper "github.com/zitadel/oidc/pkg/http"
2827
"github.com/zitadel/oidc/pkg/oidc"
29-
"golang.org/x/crypto/bcrypt"
3028
"golang.org/x/oauth2"
3129
githubOAuth "golang.org/x/oauth2/github"
3230

@@ -46,9 +44,9 @@ const (
4644
)
4745

4846
type AuthnMiddleware struct {
49-
credMap map[string]string
50-
ldapClient *LDAPClient
51-
log log.Logger
47+
htpasswdClient *HtpasswdClient
48+
ldapClient *LDAPClient
49+
log log.Logger
5250
}
5351

5452
func AuthHandler(ctlr *Controller) mux.MiddlewareFunc {
@@ -109,10 +107,10 @@ func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, userAc *reqCtx.UserAcce
109107
return false, nil
110108
}
111109

112-
passphraseHash, ok := amw.credMap[identity]
110+
passphraseHash, ok := amw.htpasswdClient.Get(identity)
113111
if ok {
114112
// first, HTTPPassword authN (which is local)
115-
if err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase)); err == nil {
113+
if err := amw.htpasswdClient.CheckPassword(identity, passphraseHash); err == nil {
116114
// Process request
117115
var groups []string
118116

@@ -254,8 +252,6 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
254252
return noPasswdAuth(ctlr)
255253
}
256254

257-
amw.credMap = make(map[string]string)
258-
259255
delay := ctlr.Config.HTTP.Auth.FailDelay
260256

261257
// ldap and htpasswd based authN
@@ -304,25 +300,6 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
304300
}
305301
}
306302

307-
if ctlr.Config.IsHtpasswdAuthEnabled() {
308-
credsFile, err := os.Open(ctlr.Config.HTTP.Auth.HTPasswd.Path)
309-
if err != nil {
310-
amw.log.Panic().Err(err).Str("credsFile", ctlr.Config.HTTP.Auth.HTPasswd.Path).
311-
Msg("failed to open creds-file")
312-
}
313-
defer credsFile.Close()
314-
315-
scanner := bufio.NewScanner(credsFile)
316-
317-
for scanner.Scan() {
318-
line := scanner.Text()
319-
if strings.Contains(line, ":") {
320-
tokens := strings.Split(scanner.Text(), ":")
321-
amw.credMap[tokens[0]] = tokens[1]
322-
}
323-
}
324-
}
325-
326303
// openid based authN
327304
if ctlr.Config.IsOpenIDAuthEnabled() {
328305
ctlr.RelyingParties = make(map[string]rp.RelyingParty)

pkg/api/constants/consts.go

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
LoginPath = AppNamespacePath + "/auth/login"
2020
LogoutPath = AppNamespacePath + "/auth/logout"
2121
APIKeyPath = AppNamespacePath + "/auth/apikey"
22+
ChangePasswordPath = AppNamespacePath + "/auth/change_password"
2223
SessionClientHeaderName = "X-ZOT-API-CLIENT"
2324
SessionClientHeaderValue = "zot-ui"
2425
APIKeysPrefix = "zak_"

pkg/api/controller.go

+13-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Controller struct {
4949
RelyingParties map[string]rp.RelyingParty
5050
CookieStore *CookieStore
5151
taskScheduler *scheduler.Scheduler
52+
htpasswdClient *HtpasswdClient
5253
// runtime params
5354
chosenPort int // kernel-chosen port
5455
}
@@ -98,7 +99,9 @@ func (c *Controller) Run() error {
9899
return err
99100
}
100101

101-
c.StartBackgroundTasks()
102+
if err := c.initHtpasswdClient(); err != nil {
103+
return err
104+
}
102105

103106
// setup HTTP API router
104107
engine := mux.NewRouter()
@@ -279,6 +282,15 @@ func (c *Controller) initCookieStore() error {
279282
return nil
280283
}
281284

285+
func (c *Controller) initHtpasswdClient() error {
286+
if c.Config.IsHtpasswdAuthEnabled() {
287+
c.htpasswdClient = NewHtpasswdClient(c.Config.HTTP.Auth.HTPasswd.Path)
288+
return c.htpasswdClient.Init()
289+
}
290+
291+
return nil
292+
}
293+
282294
func (c *Controller) InitMetaDB() error {
283295
// init metaDB if search is enabled or we need to store user profiles, api keys or signatures
284296
if c.Config.IsSearchEnabled() || c.Config.IsBasicAuthnEnabled() || c.Config.IsImageTrustEnabled() ||

pkg/api/controller_test.go

+54
Original file line numberDiff line numberDiff line change
@@ -4446,6 +4446,60 @@ func TestAuthorization(t *testing.T) {
44464446
})
44474447
}
44484448

4449+
func TestChangePassword(t *testing.T) {
4450+
Convey("Make a new controller", t, func() {
4451+
port := test.GetFreePort()
4452+
baseURL := test.GetBaseURL(port)
4453+
conf := config.New()
4454+
conf.HTTP.Port = port
4455+
username, seedUser := test.GenerateRandomString()
4456+
password, seedPass := test.GenerateRandomString()
4457+
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password))
4458+
defer os.Remove(htpasswdPath)
4459+
4460+
conf.HTTP.Auth = &config.AuthConfig{
4461+
HTPasswd: config.AuthHTPasswd{
4462+
Path: htpasswdPath,
4463+
},
4464+
}
4465+
conf.HTTP.AccessControl = &config.AccessControlConfig{
4466+
Repositories: config.Repositories{
4467+
test.AuthorizationAllRepos: config.PolicyGroup{
4468+
Policies: []config.Policy{
4469+
{
4470+
Users: []string{},
4471+
Actions: []string{},
4472+
},
4473+
},
4474+
DefaultPolicy: []string{},
4475+
},
4476+
},
4477+
AdminPolicy: config.Policy{
4478+
Users: []string{},
4479+
Actions: []string{},
4480+
},
4481+
}
4482+
4483+
Convey("with basic auth", func() {
4484+
ctlr := api.NewController(conf)
4485+
ctlr.Config.Storage.RootDirectory = t.TempDir()
4486+
4487+
err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1",
4488+
ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log))
4489+
So(err, ShouldBeNil)
4490+
4491+
cm := test.NewControllerManager(ctlr)
4492+
cm.StartAndWait(port)
4493+
defer cm.StopServer()
4494+
4495+
client := resty.New()
4496+
client.SetBasicAuth(username, password)
4497+
4498+
RunAuthorizationTests(t, client, baseURL, username, conf)
4499+
})
4500+
})
4501+
}
4502+
44494503
func TestGetUsername(t *testing.T) {
44504504
Convey("Make a new controller", t, func() {
44514505
port := test.GetFreePort()

pkg/api/htpasswd.go

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package api
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"golang.org/x/crypto/bcrypt"
7+
"os"
8+
"strings"
9+
"sync"
10+
zerr "zotregistry.io/zot/errors"
11+
)
12+
13+
type HtpasswdClient struct {
14+
credMap credMap
15+
filepath string
16+
}
17+
18+
type credMap struct {
19+
m map[string]string
20+
rw *sync.RWMutex
21+
}
22+
23+
func NewHtpasswdClient(filepath string) *HtpasswdClient {
24+
return &HtpasswdClient{
25+
filepath: filepath,
26+
credMap: credMap{
27+
m: make(map[string]string),
28+
rw: &sync.RWMutex{},
29+
},
30+
}
31+
}
32+
33+
// Init initializes the HtpasswdClient.
34+
// It performs the file read using the filename specified in NewHtpasswdClient
35+
// and caches all user passwords.
36+
func (hc *HtpasswdClient) Init() error {
37+
credsFile, err := os.Open(hc.filepath)
38+
if err != nil {
39+
return fmt.Errorf("error occured while opening creds-file: %w", err)
40+
}
41+
defer credsFile.Close()
42+
43+
hc.credMap.rw.Lock()
44+
defer hc.credMap.rw.Unlock()
45+
46+
scanner := bufio.NewScanner(credsFile)
47+
for scanner.Scan() {
48+
line := scanner.Text()
49+
if strings.Contains(line, ":") {
50+
tokens := strings.Split(line, ":")
51+
if len(tokens) > 1 {
52+
hc.credMap.m[tokens[0]] = tokens[1]
53+
}
54+
}
55+
}
56+
57+
if err := scanner.Err(); err != nil {
58+
return fmt.Errorf("error occured while reading creds-file: %w", err)
59+
}
60+
61+
return nil
62+
}
63+
64+
// Get returns the password associated with the login and a bool
65+
// indicating whether the login was found.
66+
// It does not check whether the user's password is correct.
67+
func (hc *HtpasswdClient) Get(login string) (string, bool) {
68+
return hc.credMap.Get(login)
69+
}
70+
71+
// Set sets the new password. It does not perform any checks,
72+
// the only error is possible is encryption error.
73+
func (hc *HtpasswdClient) Set(login, password string) error {
74+
return hc.credMap.Set(login, password)
75+
}
76+
77+
// CheckPassword checks whether the user has a specified password.
78+
// It returns an error if the user is not found or passwords do not match,
79+
// and returns the nil on passwords match.
80+
func (hc *HtpasswdClient) CheckPassword(login, password string) error {
81+
passwordHash, ok := hc.Get(login)
82+
if !ok {
83+
return zerr.ErrUserIsNotFound
84+
}
85+
86+
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
87+
if err != nil {
88+
return zerr.ErrPasswordsDoNotMatch
89+
}
90+
91+
return nil
92+
}
93+
94+
// ChangePassword changes the user password.
95+
// It accepts user login, his supposed old password for verification and new password.
96+
func (hc *HtpasswdClient) ChangePassword(login, supposedOldPassword, newPassword string) error {
97+
if len(newPassword) == 0 {
98+
return zerr.ErrPasswordIsEmpty
99+
}
100+
101+
hc.credMap.rw.RLock()
102+
oldPassphrase, ok := hc.credMap.m[login]
103+
hc.credMap.rw.RUnlock()
104+
if !ok {
105+
return zerr.ErrUserIsNotFound
106+
}
107+
108+
// given old password must match actual old password
109+
if err := bcrypt.CompareHashAndPassword([]byte(oldPassphrase), []byte(supposedOldPassword)); err != nil {
110+
return zerr.ErrOldPasswordIsWrong
111+
}
112+
113+
// if passwords match, no need to update file and map, return nil as if operation is successful
114+
if err := bcrypt.CompareHashAndPassword([]byte(oldPassphrase), []byte(newPassword)); err == nil {
115+
return nil
116+
}
117+
118+
// encrypt new password
119+
newPassphrase, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
120+
if err != nil {
121+
return fmt.Errorf("error occured while encrypting new password: %w", err)
122+
}
123+
124+
file, err := os.ReadFile(hc.filepath)
125+
if err != nil {
126+
return fmt.Errorf("error occured while reading creds-file: %w", err)
127+
}
128+
129+
// read passwords line by line to find the corresponding login
130+
lines := strings.Split(string(file), "\n")
131+
for i, line := range lines {
132+
if tokens := strings.SplitN(line, ":", 2); len(tokens) >= 2 {
133+
if tokens[0] == login {
134+
lines[i] = tokens[0] + ":" + string(newPassphrase)
135+
break
136+
}
137+
}
138+
}
139+
140+
// write new content to file
141+
output := strings.Join(lines, "\n")
142+
err = os.WriteFile(hc.filepath, []byte(output), 0644)
143+
if err != nil {
144+
return fmt.Errorf("error occured while writing to creds-file: %w", err)
145+
}
146+
147+
// set to credMap only if all file operations are successful to prevent collisions
148+
hc.credMap.rw.Lock()
149+
hc.credMap.m[login] = string(newPassphrase)
150+
hc.credMap.rw.Unlock()
151+
152+
return nil
153+
}
154+
155+
func (c credMap) Set(login, password string) error {
156+
passphrase, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
157+
if err != nil {
158+
return fmt.Errorf("error occured while cheking passwords: %w", err)
159+
}
160+
161+
c.rw.Lock()
162+
c.m[login] = string(passphrase)
163+
c.rw.Unlock()
164+
165+
return nil
166+
}
167+
168+
func (c credMap) Get(login string) (string, bool) {
169+
c.rw.RLock()
170+
defer c.rw.RUnlock()
171+
passphrase, ok := c.m[login]
172+
return passphrase, ok
173+
}

0 commit comments

Comments
 (0)