Skip to content

Commit 77652ad

Browse files
committed
feat: add changing user password route for htpasswd
Signed-off-by: onidoru <[email protected]>
1 parent ee9bbb0 commit 77652ad

File tree

12 files changed

+775
-29
lines changed

12 files changed

+775
-29
lines changed

errors/errors.go

Lines changed: 10 additions & 0 deletions
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

Lines changed: 5 additions & 28 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 14 additions & 1 deletion
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,16 @@ 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+
289+
return c.htpasswdClient.Init()
290+
}
291+
292+
return nil
293+
}
294+
282295
func (c *Controller) InitMetaDB() error {
283296
// init metaDB if search is enabled or we need to store user profiles, api keys or signatures
284297
if c.Config.IsSearchEnabled() || c.Config.IsBasicAuthnEnabled() || c.Config.IsImageTrustEnabled() ||

pkg/api/controller_test.go

Lines changed: 54 additions & 0 deletions
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

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

0 commit comments

Comments
 (0)