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:
parent
7e07bae4d6
commit
3893eec714
4 changed files with 320 additions and 46 deletions
|
@ -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
|
||||
|
|
|
@ -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
198
pkg/api/htpasswd.go
Normal 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
64
pkg/api/htpasswd_test.go
Normal 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)
|
||||
})
|
||||
}
|
Loading…
Add table
Reference in a new issue