Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add changing user password route for htpasswd #2183

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,12 @@ 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")

// 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 @@ -27,7 +26,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 @@ -47,9 +45,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 @@ -110,10 +108,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 @@ -255,8 +253,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 @@ -308,25 +304,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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"chpasswd"

SessionClientHeaderName = "X-ZOT-API-CLIENT"
SessionClientHeaderValue = "zot-ui"
APIKeysPrefix = "zak_"
Expand Down
17 changes: 17 additions & 0 deletions pkg/api/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Controller struct {
CookieStore *CookieStore
LDAPClient *LDAPClient
taskScheduler *scheduler.Scheduler
HtpasswdClient *HtpasswdClient
// runtime params
chosenPort int // kernel-chosen port
}
Expand Down Expand Up @@ -243,6 +244,12 @@ func (c *Controller) Init() error {

c.InitCVEInfo()

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

return nil
}

Expand Down Expand Up @@ -280,6 +287,16 @@ 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
209 changes: 209 additions & 0 deletions pkg/api/htpasswd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package api

import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"golang.org/x/crypto/bcrypt"

zerr "zotregistry.dev/zot/errors"
"zotregistry.dev/zot/pkg/storage/constants"
)

const (
htpasswdValidTokensNumber = 2
)

type HtpasswdClient struct {
credMap credMap
credFile credFile
}

type credFile struct {
path string
rw *sync.RWMutex
}

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

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

// Init initializes the HtpasswdClient.
// It performs the credFile read using the filename specified in NewHtpasswdClient
// and caches all user passwords.
func (hc *HtpasswdClient) Init() error {
credsFile, err := os.Open(hc.credFile.path)
if err != nil {
return fmt.Errorf("error occurred while opening creds-credFile: %w", err)
}
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) == htpasswdValidTokensNumber {
hc.credMap.m[tokens[0]] = tokens[1]
}
}
}

if err := scanner.Err(); err != nil {
return fmt.Errorf("error occurred while reading creds-credFile: %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 {
passphrase, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("error occurred while cheking passwords: %w", err)
}

return hc.credMap.Set(login, string(passphrase))
}

// 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.ErrBadUser
}

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
}

oldPassphrase, ok := hc.credMap.Get(login)
if !ok {
return zerr.ErrBadUser
}

// 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 credFile 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 occurred while encrypting new password: %w", err)
}

file, err := os.ReadFile(hc.credFile.path)
if err != nil {
return fmt.Errorf("error occurred while reading creds-credFile: %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.Split(line, ":"); len(tokens) == htpasswdValidTokensNumber {
if tokens[0] == login {
lines[i] = tokens[0] + ":" + string(newPassphrase)

break
}
}
}

// write new content to temporary credFile
// and replace the old credFile with temporary, so the operation is atomic
output := []byte(strings.Join(lines, "\n"))

tmpfile, err := os.CreateTemp(filepath.Dir(hc.credFile.path), "htpasswd-*.tmp")
if err != nil {
return fmt.Errorf("error occurred when creating temp htpasswd credFile: %w", err)
}

if _, err := tmpfile.Write(output); err != nil {
tmpfile.Close()
os.Remove(tmpfile.Name())

return fmt.Errorf("error occurred when writing to temp htpasswd credFile: %w", err)
}

if err := tmpfile.Close(); err != nil {
os.Remove(tmpfile.Name())

return fmt.Errorf("error occurred when closing temp htpasswd credFile: %w", err)
}

if err := os.Rename(tmpfile.Name(), hc.credFile.path); err != nil {
return fmt.Errorf("error occurred while replacing htpasswd credFile with new credFile: %w", err)
}

err = os.WriteFile(hc.credFile.path, output, constants.DefaultDirPerms)
if err != nil {
return fmt.Errorf("error occurred while writing to creds-credFile: %w", err)
}

// set to credMap only if all credFile operations are successful to prevent collisions
return hc.credMap.Set(login, string(newPassphrase))
}

func (c credMap) Set(login, passphrase string) error {
c.rw.Lock()
c.m[login] = 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
Loading