loginsrv

Unnamed repository; edit this file 'description' to name the repository.
git clone git@jamesshield.xyz:repos/loginsrv.git
Log | Files | Refs | README | LICENSE

commit d881bd3ab91f7c03796569c6c57abe5e5cba92d3
parent adb465e486850d54a0b3d3151749bb9f03a875fc
Author: Sebastian Mancke <s.mancke@tarent.de>
Date:   Wed, 28 Jun 2017 14:34:00 +0200

Merge pull request #34 from tarent/feature/multiple-comma-separated-htpasswd-files

Feature/multiple comma separated htpasswd files
Diffstat:
Mhtpasswd/auth.go | 114++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mhtpasswd/auth_test.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mhtpasswd/backend.go | 26+++++++++++++++++++++-----
Mhtpasswd/backend_test.go | 56+++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 200 insertions(+), 71 deletions(-)

diff --git a/htpasswd/auth.go b/htpasswd/auth.go @@ -8,6 +8,7 @@ import ( "encoding/csv" "fmt" "github.com/abbot/go-http-auth" + "github.com/tarent/loginsrv/logging" "golang.org/x/crypto/bcrypt" "io" "os" @@ -16,64 +17,83 @@ import ( "time" ) -// Auth is the htpassword authenticater -type Auth struct { - filename string - userHash map[string]string +// File is a struct to serve an individual modTime +type File struct { + name string // Used in func reloadIfChanged to reload htpasswd file if it changed modTime time.Time - mu sync.RWMutex +} + +// Auth is the htpassword authenticater +type Auth struct { + filenames []File + userHash map[string]string + muUserHash sync.RWMutex } // NewAuth creates an htpassword authenticater -func NewAuth(filename string) (*Auth, error) { +func NewAuth(filenames []string) (*Auth, error) { + var htpasswdFiles []File + for _, file := range filenames { + htpasswdFiles = append(htpasswdFiles, File{name: file}) + } + a := &Auth{ - filename: filename, + filenames: htpasswdFiles, } - return a, a.parse(filename) + return a, a.parse(htpasswdFiles) } -func (a *Auth) parse(filename string) error { - r, err := os.Open(filename) - if err != nil { - return err - } +func (a *Auth) parse(filenames []File) error { + tmpUserHash := map[string]string{} - fileInfo, err := os.Stat(filename) - if err != nil { - return err - } - a.modTime = fileInfo.ModTime() - - cr := csv.NewReader(r) - cr.Comma = ':' - cr.Comment = '#' - cr.TrimLeadingSpace = true - - a.mu.Lock() - defer a.mu.Unlock() - a.userHash = map[string]string{} - for { - record, err := cr.Read() - if err == io.EOF { - break + for _, filename := range a.filenames { + r, err := os.Open(filename.name) + if err != nil { + return err } + + fileInfo, err := os.Stat(filename.name) if err != nil { return err } - if len(record) != 2 { - return fmt.Errorf("password file in wrong format (%v)", filename) + filename.modTime = fileInfo.ModTime() + + cr := csv.NewReader(r) + cr.Comma = ':' + cr.Comment = '#' + cr.TrimLeadingSpace = true + + for { + record, err := cr.Read() + if err == io.EOF { + break + } + if err != nil { + return err + } + if len(record) != 2 { + return fmt.Errorf("password file in wrong format (%v)", filename) + } + + if _, exist := tmpUserHash[record[0]]; exist { + logging.Logger.Warnf("Found duplicate entry for user: (%v)", record[0]) + } + tmpUserHash[record[0]] = record[1] } - a.userHash[record[0]] = record[1] } + a.muUserHash.Lock() + a.userHash = tmpUserHash + a.muUserHash.Unlock() + return nil } // Authenticate the user func (a *Auth) Authenticate(username, password string) (bool, error) { reloadIfChanged(a) - a.mu.RLock() - defer a.mu.RUnlock() + a.muUserHash.RLock() + defer a.muUserHash.RUnlock() if hash, exist := a.userHash[username]; exist { h := []byte(hash) p := []byte(password) @@ -94,17 +114,17 @@ func (a *Auth) Authenticate(username, password string) (bool, error) { // Reload htpasswd file if it changed during current run func reloadIfChanged(a *Auth) { - fileInfo, err := os.Stat(a.filename) - if err != nil { - //On error, retain current file - return - } - - currentmodTime := fileInfo.ModTime() - - if currentmodTime != a.modTime { - a.modTime = currentmodTime - a.parse(a.filename) + for _, file := range a.filenames { + fileInfo, err := os.Stat(file.name) + if err != nil { + //On error, retain current file + break + } + currentmodTime := fileInfo.ModTime() + if currentmodTime != file.modTime { + a.parse(a.filenames) + return + } } } diff --git a/htpasswd/auth_test.go b/htpasswd/auth_test.go @@ -3,6 +3,7 @@ package htpasswd import ( . "github.com/stretchr/testify/assert" "io/ioutil" + "os" "testing" "time" ) @@ -38,8 +39,9 @@ func TestAuth_Hashes(t *testing.T) { } func TestAuth_ReloadFile(t *testing.T) { - filename := writeTmpfile(`bob:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`) - auth, err := NewAuth(filename) + files := writeTmpfile(`bob:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`) + + auth, err := NewAuth(files) NoError(t, err) authenticated, err := auth.Authenticate("bob", "secret") @@ -53,7 +55,7 @@ func TestAuth_ReloadFile(t *testing.T) { // The refresh is time based, so we have to wait a second, here time.Sleep(time.Second) - err = ioutil.WriteFile(filename, []byte(`alice:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`), 06644) + err = ioutil.WriteFile(files[0], []byte(`alice:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`), 06644) NoError(t, err) authenticated, err = auth.Authenticate("bob", "secret") @@ -65,6 +67,47 @@ func TestAuth_ReloadFile(t *testing.T) { True(t, authenticated) } +func TestAuth_FromTwoFiles(t *testing.T) { + files := writeTmpfile(`bob:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`, `alice:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`) + + auth, err := NewAuth(files) + NoError(t, err) + + authenticated, err := auth.Authenticate("bob", "secret") + NoError(t, err) + True(t, authenticated) + + authenticated, err = auth.Authenticate("alice", "secret") + NoError(t, err) + True(t, authenticated) +} + +func TestAuth_ReloadFileDeleted(t *testing.T) { + files := writeTmpfile(`bob:$apr1$IDZSCL/o$N68zaFDDRivjour94OVeB.`) + + auth, err := NewAuth(files) + NoError(t, err) + + authenticated, err := auth.Authenticate("bob", "secret") + NoError(t, err) + True(t, authenticated) + + authenticated, err = auth.Authenticate("alice", "secret") + NoError(t, err) + False(t, authenticated) + + // The refresh is time based, so we have to wait a second, here + time.Sleep(time.Second) + + // delete file and load auth from "cache" + _ = os.Remove(files[0]) + + authenticated, err = auth.Authenticate("bob", "secret") + NoError(t, err) + True(t, authenticated) + +} + func TestAuth_UnknownUser(t *testing.T) { auth, err := NewAuth(writeTmpfile(testfile)) NoError(t, err) @@ -75,7 +118,7 @@ func TestAuth_UnknownUser(t *testing.T) { } func TestAuth_ErrorOnMissingFile(t *testing.T) { - _, err := NewAuth("/tmp/foo/bar/nothing") + _, err := NewAuth([]string{"/tmp/foo/bar/nothing"}) Error(t, err) } @@ -106,15 +149,19 @@ func TestAuth_Hashes_UnknownAlgoError(t *testing.T) { False(t, authenticated) } -func writeTmpfile(contents string) string { - f, err := ioutil.TempFile("", "loginsrv_htpasswdtest") - if err != nil { - panic(err) - } - defer f.Close() - _, err = f.WriteString(contents) - if err != nil { - panic(err) +func writeTmpfile(contents ...string) []string { + var names []string + for _, curContent := range contents { + f, err := ioutil.TempFile("", "loginsrv_htpasswdtest") + if err != nil { + panic(err) + } + defer f.Close() + _, err = f.WriteString(curContent) + if err != nil { + panic(err) + } + names = append(names, f.Name()) } - return f.Name() + return names } diff --git a/htpasswd/backend.go b/htpasswd/backend.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/tarent/loginsrv/login" "github.com/tarent/loginsrv/model" + "strings" ) // ProviderName const @@ -13,17 +14,32 @@ func init() { login.RegisterProvider( &login.ProviderDescription{ Name: ProviderName, - HelpText: "Htpasswd login backend opts: file=/path/to/pwdfile", + HelpText: "Htpasswd login backend opts: files=/path/to/pwdfile,/path/to/additionalfile", }, BackendFactory) } // BackendFactory creates a htpasswd backend func BackendFactory(config map[string]string) (login.Backend, error) { + var files []string + + if f, exist := config["files"]; exist { + for _, file := range strings.Split(f, ",") { + files = append(files, file) + } + } + if f, exist := config["file"]; exist { - return NewBackend(f) + for _, file := range strings.Split(f, ",") { + files = append(files, file) + } } - return nil, errors.New(`missing parameter "file" for htpasswd provider`) + + if len(files) == 0 { + return nil, errors.New(`missing parameter "file" for htpasswd provider`) + } + + return NewBackend(files) } // Backend is a htpasswd based authentication backend. @@ -32,8 +48,8 @@ type Backend struct { } // NewBackend creates a new Backend and verifies the parameters. -func NewBackend(filename string) (*Backend, error) { - auth, err := NewAuth(filename) +func NewBackend(filenames []string) (*Backend, error) { + auth, err := NewAuth(filenames) return &Backend{ auth, }, err diff --git a/htpasswd/backend_test.go b/htpasswd/backend_test.go @@ -3,23 +3,69 @@ package htpasswd import ( . "github.com/stretchr/testify/assert" "github.com/tarent/loginsrv/login" + "strings" "testing" ) -func TestSetup(t *testing.T) { +func TestSetupOneFile(t *testing.T) { p, exist := login.GetProvider(ProviderName) True(t, exist) NotNil(t, p) - file := writeTmpfile(testfile) + files := writeTmpfile(testfile) backend, err := p(map[string]string{ - "file": file, + "file": files[0], }) NoError(t, err) Equal(t, - file, - backend.(*Backend).auth.filename) + []File{File{name: files[0]}}, + backend.(*Backend).auth.filenames) +} + +func TestSetupTwoFiles(t *testing.T) { + p, exist := login.GetProvider(ProviderName) + True(t, exist) + NotNil(t, p) + + filenames := writeTmpfile(testfile, testfile) + + var morphed []File + for _, curFile := range filenames { + morphed = append(morphed, File{name: curFile}) + } + backend, err := p(map[string]string{ + "file": strings.Join(filenames, ","), + }) + + NoError(t, err) + Equal(t, + morphed, + backend.(*Backend).auth.filenames) +} + +func TestSetupTwoConfigs(t *testing.T) { + p, exist := login.GetProvider(ProviderName) + True(t, exist) + NotNil(t, p) + + configFiles := writeTmpfile(testfile, testfile) + configFile := writeTmpfile(testfile, testfile) + + var morphed []File + for _, curFile := range append(configFiles, configFile...) { + morphed = append(morphed, File{name: curFile}) + } + + backend, err := p(map[string]string{ + "files": strings.Join(configFiles, ","), + "file": strings.Join(configFile, ","), + }) + + NoError(t, err) + Equal(t, + morphed, + backend.(*Backend).auth.filenames) } func TestSetup_Error(t *testing.T) {