Skip to content

Commit

Permalink
feat: add changing user password route for htpasswd
Browse files Browse the repository at this point in the history
  • Loading branch information
onidoru committed Jan 25, 2024
1 parent ee9bbb0 commit c987ffe
Show file tree
Hide file tree
Showing 12 changed files with 755 additions and 29 deletions.
10 changes: 10 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,14 @@ var (
ErrAPINotSupported = errors.New("registry at the given address doesn't implement the correct API")
ErrURLNotFound = errors.New("url not found")
ErrInvalidSearchQuery = errors.New("invalid search query")

// ErrUserIsNotFound returned if the user is not found.
ErrUserIsNotFound = errors.New("user is not found")
// ErrPasswordsDoNotMatch returned if given password does not match existing user's password.
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
// ErrOldPasswordIsWrong returned if provided old password for user verification
// during the password change is wrong.
ErrOldPasswordIsWrong = errors.New("old password is wrong")
// ErrPasswordIsEmpty returned if user's new password is empty
ErrPasswordIsEmpty = errors.New("password can not be empty")
)
33 changes: 5 additions & 28 deletions pkg/api/authn.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package api

import (
"bufio"
"context"
"crypto/sha256"
"crypto/x509"
Expand All @@ -26,7 +25,6 @@ import (
"github.com/zitadel/oidc/pkg/client/rp"
httphelper "github.com/zitadel/oidc/pkg/http"
"github.com/zitadel/oidc/pkg/oidc"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
githubOAuth "golang.org/x/oauth2/github"

Expand All @@ -46,9 +44,9 @@ const (
)

type AuthnMiddleware struct {
credMap map[string]string
ldapClient *LDAPClient
log log.Logger
htpasswdClient *HtpasswdClient
ldapClient *LDAPClient
log log.Logger
}

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

passphraseHash, ok := amw.credMap[identity]
passphraseHash, ok := amw.htpasswdClient.Get(identity)
if ok {
// first, HTTPPassword authN (which is local)
if err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase)); err == nil {
if err := amw.htpasswdClient.CheckPassword(identity, passphraseHash); err == nil {
// Process request
var groups []string

Expand Down Expand Up @@ -254,8 +252,6 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
return noPasswdAuth(ctlr)
}

amw.credMap = make(map[string]string)

delay := ctlr.Config.HTTP.Auth.FailDelay

// ldap and htpasswd based authN
Expand Down Expand Up @@ -304,25 +300,6 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
}
}

if ctlr.Config.IsHtpasswdAuthEnabled() {
credsFile, err := os.Open(ctlr.Config.HTTP.Auth.HTPasswd.Path)
if err != nil {
amw.log.Panic().Err(err).Str("credsFile", ctlr.Config.HTTP.Auth.HTPasswd.Path).
Msg("failed to open creds-file")
}
defer credsFile.Close()

scanner := bufio.NewScanner(credsFile)

for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, ":") {
tokens := strings.Split(scanner.Text(), ":")
amw.credMap[tokens[0]] = tokens[1]
}
}
}

// openid based authN
if ctlr.Config.IsOpenIDAuthEnabled() {
ctlr.RelyingParties = make(map[string]rp.RelyingParty)
Expand Down
1 change: 1 addition & 0 deletions pkg/api/constants/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
LoginPath = AppNamespacePath + "/auth/login"
LogoutPath = AppNamespacePath + "/auth/logout"
APIKeyPath = AppNamespacePath + "/auth/apikey"
ChangePasswordPath = AppNamespacePath + "/auth/change_password"
SessionClientHeaderName = "X-ZOT-API-CLIENT"
SessionClientHeaderValue = "zot-ui"
APIKeysPrefix = "zak_"
Expand Down
14 changes: 13 additions & 1 deletion pkg/api/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type Controller struct {
RelyingParties map[string]rp.RelyingParty
CookieStore *CookieStore
taskScheduler *scheduler.Scheduler
htpasswdClient *HtpasswdClient
// runtime params
chosenPort int // kernel-chosen port
}
Expand Down Expand Up @@ -98,7 +99,9 @@ func (c *Controller) Run() error {
return err
}

c.StartBackgroundTasks()
if err := c.initHtpasswdClient(); err != nil {
return err
}

// setup HTTP API router
engine := mux.NewRouter()
Expand Down Expand Up @@ -279,6 +282,15 @@ func (c *Controller) initCookieStore() error {
return nil
}

func (c *Controller) initHtpasswdClient() error {
if c.Config.IsHtpasswdAuthEnabled() {
c.htpasswdClient = NewHtpasswdClient(c.Config.HTTP.Auth.HTPasswd.Path)
return c.htpasswdClient.Init()
}

return nil
}

func (c *Controller) InitMetaDB() error {
// init metaDB if search is enabled or we need to store user profiles, api keys or signatures
if c.Config.IsSearchEnabled() || c.Config.IsBasicAuthnEnabled() || c.Config.IsImageTrustEnabled() ||
Expand Down
54 changes: 54 additions & 0 deletions pkg/api/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4446,6 +4446,60 @@ func TestAuthorization(t *testing.T) {
})
}

func TestChangePassword(t *testing.T) {
Convey("Make a new controller", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
username, seedUser := test.GenerateRandomString()
password, seedPass := test.GenerateRandomString()
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password))
defer os.Remove(htpasswdPath)

conf.HTTP.Auth = &config.AuthConfig{
HTPasswd: config.AuthHTPasswd{

Check failure on line 4461 in pkg/api/controller_test.go

View workflow job for this annotation

GitHub Actions / Running privileged tests on Linux

seedUser declared and not used

Check failure on line 4461 in pkg/api/controller_test.go

View workflow job for this annotation

GitHub Actions / Run zot with extensions tests

seedUser declared and not used
Path: htpasswdPath,

Check failure on line 4462 in pkg/api/controller_test.go

View workflow job for this annotation

GitHub Actions / Running privileged tests on Linux

seedPass declared and not used

Check failure on line 4462 in pkg/api/controller_test.go

View workflow job for this annotation

GitHub Actions / Run zot with extensions tests

seedPass declared and not used
},
}
conf.HTTP.AccessControl = &config.AccessControlConfig{
Repositories: config.Repositories{
test.AuthorizationAllRepos: config.PolicyGroup{
Policies: []config.Policy{
{
Users: []string{},
Actions: []string{},
},
},
DefaultPolicy: []string{},
},
},
AdminPolicy: config.Policy{
Users: []string{},
Actions: []string{},
},
}

Convey("with basic auth", func() {
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = t.TempDir()

err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1",
ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log))
So(err, ShouldBeNil)

cm := test.NewControllerManager(ctlr)
cm.StartAndWait(port)
defer cm.StopServer()

client := resty.New()
client.SetBasicAuth(username, password)

RunAuthorizationTests(t, client, baseURL, username, conf)
})
})
}

func TestGetUsername(t *testing.T) {
Convey("Make a new controller", t, func() {
port := test.GetFreePort()
Expand Down
173 changes: 173 additions & 0 deletions pkg/api/htpasswd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package api

import (
"bufio"
"fmt"
"golang.org/x/crypto/bcrypt"
"os"
"strings"
"sync"
zerr "zotregistry.io/zot/errors"
)

type HtpasswdClient struct {
credMap credMap
filepath string
}

type credMap struct {
m map[string]string
rw *sync.RWMutex
}

func NewHtpasswdClient(filepath string) *HtpasswdClient {
return &HtpasswdClient{
filepath: filepath,
credMap: credMap{
m: make(map[string]string),
rw: &sync.RWMutex{},
},
}
}

// Init initializes the HtpasswdClient.
// It performs the file read using the filename specified in NewHtpasswdClient
// and caches all user passwords.
func (hc *HtpasswdClient) Init() error {
credsFile, err := os.Open(hc.filepath)
if err != nil {
return fmt.Errorf("error occured while opening creds-file: %w", err)

Check failure on line 39 in pkg/api/htpasswd.go

View workflow job for this annotation

GitHub Actions / lint

`occured` is a misspelling of `occurred` (misspell)
}
defer credsFile.Close()

hc.credMap.rw.Lock()
defer hc.credMap.rw.Unlock()

scanner := bufio.NewScanner(credsFile)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, ":") {
tokens := strings.Split(line, ":")
if len(tokens) > 1 {
hc.credMap.m[tokens[0]] = tokens[1]
}
}
}

if err := scanner.Err(); err != nil {
return fmt.Errorf("error occured while reading creds-file: %w", err)
}

return nil
}

// Get returns the password associated with the login and a bool
// indicating whether the login was found.
// It does not check whether the user's password is correct.
func (hc *HtpasswdClient) Get(login string) (string, bool) {
return hc.credMap.Get(login)
}

// Set sets the new password. It does not perform any checks,
// the only error is possible is encryption error.
func (hc *HtpasswdClient) Set(login, password string) error {
return hc.credMap.Set(login, password)
}

// CheckPassword checks whether the user has a specified password.
// It returns an error if the user is not found or passwords do not match,
// and returns the nil on passwords match.
func (hc *HtpasswdClient) CheckPassword(login, password string) error {
passwordHash, ok := hc.Get(login)
if !ok {
return zerr.ErrUserIsNotFound
}

err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
if err != nil {
return zerr.ErrPasswordsDoNotMatch
}

return nil
}

// ChangePassword changes the user password.
// It accepts user login, his supposed old password for verification and new password.
func (hc *HtpasswdClient) ChangePassword(login, supposedOldPassword, newPassword string) error {
if len(newPassword) == 0 {
return zerr.ErrPasswordIsEmpty
}

hc.credMap.rw.RLock()
oldPassphrase, ok := hc.credMap.m[login]
hc.credMap.rw.RUnlock()
if !ok {
return zerr.ErrUserIsNotFound
}

// given old password must match actual old password
if err := bcrypt.CompareHashAndPassword([]byte(oldPassphrase), []byte(supposedOldPassword)); err != nil {
return zerr.ErrOldPasswordIsWrong
}

// if passwords match, no need to update file and map, return nil as if operation is successful
if err := bcrypt.CompareHashAndPassword([]byte(oldPassphrase), []byte(newPassword)); err == nil {
return nil
}

// encrypt new password
newPassphrase, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("error occured while encrypting new password: %w", err)
}

file, err := os.ReadFile(hc.filepath)
if err != nil {
return fmt.Errorf("error occured while reading creds-file: %w", err)
}

// read passwords line by line to find the corresponding login
lines := strings.Split(string(file), "\n")
for i, line := range lines {
if tokens := strings.SplitN(line, ":", 2); len(tokens) >= 2 {
if tokens[0] == login {
lines[i] = tokens[0] + ":" + string(newPassphrase)
break
}
}
}

// write new content to file
output := strings.Join(lines, "\n")
err = os.WriteFile(hc.filepath, []byte(output), 0644)

Check failure on line 142 in pkg/api/htpasswd.go

View workflow job for this annotation

GitHub Actions / lint

File is not `gofumpt`-ed (gofumpt)
if err != nil {
return fmt.Errorf("error occured while writing to creds-file: %w", err)
}

// set to credMap only if all file operations are successful to prevent collisions
hc.credMap.rw.Lock()
hc.credMap.m[login] = string(newPassphrase)
hc.credMap.rw.Unlock()

return nil
}

func (c credMap) Set(login, password string) error {
passphrase, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("error occured while cheking passwords: %w", err)
}

c.rw.Lock()
c.m[login] = string(passphrase)
c.rw.Unlock()

return nil
}

func (c credMap) Get(login string) (string, bool) {
c.rw.RLock()
defer c.rw.RUnlock()
passphrase, ok := c.m[login]
return passphrase, ok
}
Loading

0 comments on commit c987ffe

Please sign in to comment.