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(htpasswd): add autoreload for htpasswd #2933

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
74 changes: 30 additions & 44 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/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"

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions pkg/api/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
SyncOnDemand SyncOnDemand
RelyingParties map[string]rp.RelyingParty
CookieStore *CookieStore
HTPasswd *HTPasswd
HTPasswdWatcher *HTPasswdWatcher
LDAPClient *LDAPClient
taskScheduler *scheduler.Scheduler
// runtime params
Expand Down Expand Up @@ -98,8 +100,17 @@
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")
}

Check warning on line 108 in pkg/api/controller.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/controller.go#L107-L108

Added lines #L107 - L108 were not covered by tests

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)
Expand Down Expand Up @@ -283,6 +294,13 @@

c.InitCVEInfo()

if c.Config.IsHtpasswdAuthEnabled() {
err := c.HTPasswdWatcher.ChangeFile(c.Config.HTTP.Auth.HTPasswd.Path)
if err != nil {
return err
}

Check warning on line 301 in pkg/api/controller.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/controller.go#L300-L301

Added lines #L300 - L301 were not covered by tests
}

return nil
}

Expand Down Expand Up @@ -362,14 +380,22 @@
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")
}

Check warning on line 389 in pkg/api/controller.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/controller.go#L388-L389

Added lines #L388 - L389 were not covered by tests

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("")

Check warning on line 398 in pkg/api/controller.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/controller.go#L397-L398

Added lines #L397 - L398 were not covered by tests
}

// reload periodical gc config
Expand Down
198 changes: 198 additions & 0 deletions pkg/api/htpasswd.go
Original file line number Diff line number Diff line change
@@ -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")

Check warning on line 56 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L56

Added line #L56 was not covered by tests
} 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")
}

Check warning on line 96 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L94-L96

Added lines #L94 - L96 were not covered by tests

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
}

Check warning on line 117 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L116-L117

Added lines #L116 - L117 were not covered by tests

if filePath != "" {
err = watcher.Add(filePath)
if err != nil {
return nil, errors.Join(err, watcher.Close())
}

Check warning on line 123 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L120-L123

Added lines #L120 - L123 were not covered by tests
}

// 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")
}

Check warning on line 152 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L147-L152

Added lines #L147 - L152 were not covered by tests

case err := <-ret.watcher.Errors:
ret.log.Panic().Err(err).Str("htpasswd-file", ret.filePath).Msg("fsnotfy error while watching config")

Check warning on line 155 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L154-L155

Added lines #L154 - L155 were not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't panic a bit harsh? Do we want to stop the server in case of an error watching the credentials file?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, i copied that behaviour from config watcher. Unsure if i ever seen that error, so probably better to die, than left unpredictable state.

Copy link
Contributor

@andaaron andaaron Feb 8, 2025

Choose a reason for hiding this comment

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

A change in the config file not being applied is something impacting the entire service, while a change in htpasswd not being applied impacts just specific users who were added/removed/had the passwords changed.
I don't think we should stop the service in this specific case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But that's more like entire system error, than this file error. Or i'm wrong?

Copy link
Contributor

Choose a reason for hiding this comment

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

Well yes, but do we want to interrupt service for all users, or just the ones which don't have access anyway? Or would it help the admin with troubleshooting the issue if the entire service stops?


case <-ctx.Done():
ret.log.Debug().Msg("htpasswd watcher terminating...")

return

Check warning on line 160 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L157-L160

Added lines #L157 - L160 were not covered by tests
}
}
}()

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
}

Check warning on line 174 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L173-L174

Added lines #L173 - L174 were not covered by tests
}

if filePath == "" {
s.filePath = filePath
s.htp.Clear()

return nil
}

err := s.watcher.Add(filePath)
if err != nil {
return err
}

Check warning on line 187 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L186-L187

Added lines #L186 - L187 were not covered by tests

s.filePath = filePath

return s.htp.Reload(filePath)
}

func (s *HTPasswdWatcher) Close() error {
s.cancel()

return nil

Check warning on line 197 in pkg/api/htpasswd.go

View check run for this annotation

Codecov / codecov/patch

pkg/api/htpasswd.go#L194-L197

Added lines #L194 - L197 were not covered by tests
}
Loading