diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 5f14f6c1d..947f8e791 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -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 - - if ctlr.Config.HTTP.AccessControl != nil { - ac := NewAccessController(ctlr.Config) - groups = ac.getUserGroups(identity) - } - - userAc.SetUsername(identity) - userAc.AddGroups(groups) - userAc.SaveOnRequest(request) + // first, HTTPPassword authN (which is local) + htOk, _ := amw.htpasswd.Authenticate(identity, passphrase) + if htOk { + // Process request + var groups []string - // 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 - } - } + if ctlr.Config.HTTP.AccessControl != nil { + ac := NewAccessController(ctlr.Config) + groups = ac.getUserGroups(identity) + } - // 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") + // 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 true, nil + 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 diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 056a9bab7..24b5ac2cd 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -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 diff --git a/pkg/api/htpasswd.go b/pkg/api/htpasswd.go new file mode 100644 index 000000000..8d47a17be --- /dev/null +++ b/pkg/api/htpasswd.go @@ -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.Panic().Err(err).Str("htpasswd-file", ret.filePath).Msg("fsnotfy error while watching config") + + 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 +}