0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2025-04-08 02:54:41 -05:00

feat(htpasswd): add autoreload for htpasswd (#2933)

* feat(htpasswd): move htpasswd processing to a helper struct and add reload

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>

* feat(htpasswd): use dedicated fsnotify reloader for htpasswd file

- rewrite htpasswd watcher not to store context
- improve logging

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>

* feat(htpasswd): add htpasswd reload test

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>

---------

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>
This commit is contained in:
Vladimir Ermakov 2025-02-27 11:42:57 +01:00 committed by GitHub
parent 7e07bae4d6
commit 3893eec714
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 320 additions and 46 deletions

View file

@ -1,7 +1,6 @@
package api
import (
"bufio"
"context"
"crypto/sha256"
"crypto/x509"
@ -27,7 +26,6 @@ import (
"github.com/zitadel/oidc/v3/pkg/client/rp"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
githubOAuth "golang.org/x/oauth2/github"
@ -47,13 +45,16 @@ const (
)
type AuthnMiddleware struct {
credMap map[string]string
htpasswd *HTPasswd
ldapClient *LDAPClient
log log.Logger
}
func AuthHandler(ctlr *Controller) mux.MiddlewareFunc {
authnMiddleware := &AuthnMiddleware{log: ctlr.Log}
authnMiddleware := &AuthnMiddleware{
htpasswd: ctlr.HTPasswd,
log: ctlr.Log,
}
if ctlr.Config.IsBearerAuthEnabled() {
return bearerAuthHandler(ctlr)
@ -110,40 +111,38 @@ func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, userAc *reqCtx.UserAcce
return false, nil
}
passphraseHash, ok := amw.credMap[identity]
if ok {
// first, HTTPPassword authN (which is local)
if err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase)); err == nil {
// Process request
var groups []string
// first, HTTPPassword authN (which is local)
htOk, _ := amw.htpasswd.Authenticate(identity, passphrase)
if htOk {
// Process request
var groups []string
if ctlr.Config.HTTP.AccessControl != nil {
ac := NewAccessController(ctlr.Config)
groups = ac.getUserGroups(identity)
}
if ctlr.Config.HTTP.AccessControl != nil {
ac := NewAccessController(ctlr.Config)
groups = ac.getUserGroups(identity)
}
userAc.SetUsername(identity)
userAc.AddGroups(groups)
userAc.SaveOnRequest(request)
// saved logged session only if the request comes from web (has UI session header value)
if hasSessionHeader(request) {
if err := saveUserLoggedSession(cookieStore, response, request, identity, ctlr.Log); err != nil {
return false, err
}
}
// we have already populated the request context with userAc
if err := ctlr.MetaDB.SetUserGroups(request.Context(), groups); err != nil {
ctlr.Log.Error().Err(err).Str("identity", identity).Msg("failed to update user profile")
userAc.SetUsername(identity)
userAc.AddGroups(groups)
userAc.SaveOnRequest(request)
// saved logged session only if the request comes from web (has UI session header value)
if hasSessionHeader(request) {
if err := saveUserLoggedSession(cookieStore, response, request, identity, ctlr.Log); err != nil {
return false, err
}
ctlr.Log.Info().Str("identity", identity).Msgf("user profile successfully set")
return true, nil
}
// we have already populated the request context with userAc
if err := ctlr.MetaDB.SetUserGroups(request.Context(), groups); err != nil {
ctlr.Log.Error().Err(err).Str("identity", identity).Msg("failed to update user profile")
return false, err
}
ctlr.Log.Info().Str("identity", identity).Msgf("user profile successfully set")
return true, nil
}
// next, LDAP if configured (network-based which can lose connectivity)
@ -255,8 +254,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
@ -310,22 +307,11 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
}
if ctlr.Config.IsHtpasswdAuthEnabled() {
credsFile, err := os.Open(ctlr.Config.HTTP.Auth.HTPasswd.Path)
err := amw.htpasswd.Reload(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

View file

@ -50,6 +50,8 @@ type Controller struct {
SyncOnDemand SyncOnDemand
RelyingParties map[string]rp.RelyingParty
CookieStore *CookieStore
HTPasswd *HTPasswd
HTPasswdWatcher *HTPasswdWatcher
LDAPClient *LDAPClient
taskScheduler *scheduler.Scheduler
// runtime params
@ -98,8 +100,17 @@ func NewController(appConfig *config.Config) *Controller {
Str("clusterMemberIndex", strconv.Itoa(memberSocketIdx)).Logger()
}
htp := NewHTPasswd(logger)
htw, err := NewHTPasswdWatcher(htp, "")
if err != nil {
logger.Panic().Err(err).Msg("failed to create htpasswd watcher")
}
controller.Config = appConfig
controller.Log = logger
controller.HTPasswd = htp
controller.HTPasswdWatcher = htw
if appConfig.Log.Audit != "" {
audit := log.NewAuditLogger(appConfig.Log.Level, appConfig.Log.Audit)
@ -283,6 +294,13 @@ func (c *Controller) Init() error {
c.InitCVEInfo()
if c.Config.IsHtpasswdAuthEnabled() {
err := c.HTPasswdWatcher.ChangeFile(c.Config.HTTP.Auth.HTPasswd.Path)
if err != nil {
return err
}
}
return nil
}
@ -362,14 +380,22 @@ func (c *Controller) LoadNewConfig(newConfig *config.Config) {
c.Config.HTTP.AccessControl = newConfig.HTTP.AccessControl
if c.Config.HTTP.Auth != nil {
c.Config.HTTP.Auth.HTPasswd = newConfig.HTTP.Auth.HTPasswd
c.Config.HTTP.Auth.LDAP = newConfig.HTTP.Auth.LDAP
err := c.HTPasswdWatcher.ChangeFile(c.Config.HTTP.Auth.HTPasswd.Path)
if err != nil {
c.Log.Error().Err(err).Msg("failed to change watched htpasswd file")
}
if c.LDAPClient != nil {
c.LDAPClient.lock.Lock()
c.LDAPClient.BindDN = newConfig.HTTP.Auth.LDAP.BindDN()
c.LDAPClient.BindPassword = newConfig.HTTP.Auth.LDAP.BindPassword()
c.LDAPClient.lock.Unlock()
}
} else {
_ = c.HTPasswdWatcher.ChangeFile("")
}
// reload periodical gc config

198
pkg/api/htpasswd.go Normal file
View file

@ -0,0 +1,198 @@
package api
import (
"bufio"
"context"
"errors"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/fsnotify/fsnotify"
"golang.org/x/crypto/bcrypt"
"zotregistry.dev/zot/pkg/log"
)
// HTPasswd user auth store
//
// Currently supports only bcrypt hashes.
type HTPasswd struct {
mu sync.RWMutex
credMap map[string]string
log log.Logger
}
func NewHTPasswd(log log.Logger) *HTPasswd {
return &HTPasswd{
credMap: make(map[string]string),
log: log,
}
}
func (s *HTPasswd) Reload(filePath string) error {
credMap := make(map[string]string)
credsFile, err := os.Open(filePath)
if err != nil {
s.log.Error().Err(err).Str("htpasswd-file", filePath).Msg("failed to reload htpasswd")
return err
}
defer credsFile.Close()
scanner := bufio.NewScanner(credsFile)
for scanner.Scan() {
user, hash, ok := strings.Cut(scanner.Text(), ":")
if ok {
credMap[user] = hash
}
}
if len(credMap) == 0 {
s.log.Warn().Str("htpasswd-file", filePath).Msg("loaded htpasswd file appears to have zero users")
} else {
s.log.Info().Str("htpasswd-file", filePath).Int("users", len(credMap)).Msg("loaded htpasswd file")
}
s.mu.Lock()
defer s.mu.Unlock()
s.credMap = credMap
return nil
}
func (s *HTPasswd) Get(username string) (passphraseHash string, present bool) { //nolint: nonamedreturns
s.mu.RLock()
defer s.mu.RUnlock()
passphraseHash, present = s.credMap[username]
return
}
func (s *HTPasswd) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.credMap = make(map[string]string)
}
func (s *HTPasswd) Authenticate(username, passphrase string) (ok, present bool) { //nolint: nonamedreturns
passphraseHash, present := s.Get(username)
if !present {
return false, false
}
err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase))
ok = err == nil
if err != nil && !errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
// Log that user's hash has unsupported format. Better than silently return 401.
s.log.Warn().Err(err).Str("username", username).Msg("htpasswd bcrypt compare failed")
}
return
}
// HTPasswdWatcher helper which triggers htpasswd reload on file change event.
//
// Cannot be restarted.
type HTPasswdWatcher struct {
htp *HTPasswd
filePath string
watcher *fsnotify.Watcher
cancel context.CancelFunc
log log.Logger
}
// NewHTPasswdWatcher create and start watcher.
func NewHTPasswdWatcher(htp *HTPasswd, filePath string) (*HTPasswdWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if filePath != "" {
err = watcher.Add(filePath)
if err != nil {
return nil, errors.Join(err, watcher.Close())
}
}
// background event processor job context
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
ret := &HTPasswdWatcher{
htp: htp,
filePath: filePath,
watcher: watcher,
cancel: cancel,
log: htp.log,
}
go func() {
defer ret.watcher.Close() //nolint: errcheck
for {
select {
case ev := <-ret.watcher.Events:
if ev.Op != fsnotify.Write {
continue
}
ret.log.Info().Str("htpasswd-file", ret.filePath).Msg("htpasswd file changed, trying to reload config")
err := ret.htp.Reload(ret.filePath)
if err != nil {
ret.log.Warn().Err(err).Str("htpasswd-file", ret.filePath).Msg("failed to reload file")
}
case err := <-ret.watcher.Errors:
ret.log.Error().Err(err).Str("htpasswd-file", ret.filePath).Msg("failed to fsnotfy, got error while watching file")
case <-ctx.Done():
ret.log.Debug().Msg("htpasswd watcher terminating...")
return
}
}
}()
return ret, nil
}
// ChangeFile changes monitored file. Empty string clears store.
func (s *HTPasswdWatcher) ChangeFile(filePath string) error {
if s.filePath != "" {
err := s.watcher.Remove(s.filePath)
if err != nil {
return err
}
}
if filePath == "" {
s.filePath = filePath
s.htp.Clear()
return nil
}
err := s.watcher.Add(filePath)
if err != nil {
return err
}
s.filePath = filePath
return s.htp.Reload(filePath)
}
func (s *HTPasswdWatcher) Close() error {
s.cancel()
return nil
}

64
pkg/api/htpasswd_test.go Normal file
View file

@ -0,0 +1,64 @@
package api_test
import (
"os"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
"zotregistry.dev/zot/pkg/api"
"zotregistry.dev/zot/pkg/log"
test "zotregistry.dev/zot/pkg/test/common"
)
func TestHTPasswdWatcher(t *testing.T) {
logger := log.NewLogger("DEBUG", "")
Convey("reload htpasswd", t, func(c C) {
username, _ := test.GenerateRandomString()
password1, _ := test.GenerateRandomString()
password2, _ := test.GenerateRandomString()
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password1))
defer os.Remove(htpasswdPath)
htp := api.NewHTPasswd(logger)
htw, err := api.NewHTPasswdWatcher(htp, "")
So(err, ShouldBeNil)
defer htw.Close() //nolint: errcheck
_, present := htp.Get(username)
So(present, ShouldBeFalse)
err = htw.ChangeFile(htpasswdPath)
So(err, ShouldBeNil)
// 1. Check user present and it has password1
ok, present := htp.Authenticate(username, password1)
So(ok, ShouldBeTrue)
So(present, ShouldBeTrue)
ok, present = htp.Authenticate(username, password2)
So(ok, ShouldBeFalse)
So(present, ShouldBeTrue)
// 2. Change file
err = os.WriteFile(htpasswdPath, []byte(test.GetCredString(username, password2)), 0o600)
So(err, ShouldBeNil)
// 3. Give some time for the background task
time.Sleep(10 * time.Millisecond)
// 4. Check user present and now has password2
ok, present = htp.Authenticate(username, password1)
So(ok, ShouldBeFalse)
So(present, ShouldBeTrue)
ok, present = htp.Authenticate(username, password2)
So(ok, ShouldBeTrue)
So(present, ShouldBeTrue)
})
}