diff --git a/Makefile b/Makefile index 966e0a1..fc0f305 100644 --- a/Makefile +++ b/Makefile @@ -14,10 +14,14 @@ all: go build pw-gen/pw.go test: + cd plugin && make go test ./backends ./cache ./hashing -v -count=1 + rm plugin/*.so test-backends: + cd plugin && make go test ./backends -v -failfast -count=1 + rm plugin/*.so test-cache: go test ./cache -v -failfast -count=1 diff --git a/README.md b/README.md index ef361da..1bdbb55 100644 --- a/README.md +++ b/README.md @@ -498,8 +498,8 @@ Usage of ./pw: For this backend `passwords` and `acls` file paths must be given: ``` -auth_opt_password_path /path/to/password_file -auth_opt_acl_path /path/to/acl_file +auth_opt_files_password_path /path/to/password_file +auth_opt_files_acl_path /path/to/acl_file ``` The following are correctly formatted examples of password and acl files: @@ -824,11 +824,11 @@ There are no requirements, as the tests create (and later delete) the DB and tab ### JWT -The `jwt` backend is for auth with a JWT remote API, a local DB or a JavaScript VM interpreter. Global otions for JWT are: +The `jwt` backend is for auth with a JWT remote API, a local DB, a JavaScript VM interpreter or an ACL file. Global otions for JWT are: | Option | default | Mandatory | Meaning | | ------------------------- | ----------------- | :---------: | ------------------------------------------------------- | -| jwt_mode | | Y | local, remote, js | +| jwt_mode | | Y | local, remote, js, files | | jwt_parse_token | false | N | Parse token in remote/js modes | | jwt_secret | | Y/N | JWT secret, required for local mode, optional otherwise | | jwt_userfield | | N | When `Username`, expect `username` as part of claims | @@ -1002,7 +1002,7 @@ Since local JWT follows the underlying DB backend's way of working, both of thes #### JS mode -The last mode for this backend is JS mode, which allows to run a JavaScript interpreter VM to conduct checks. Options for this mode are: +When set to `js` JWT will act in JS mode, which allows to run a JavaScript interpreter VM to conduct checks. Options for this mode are: | Option | default | Mandatory | Meaning | | ------------------------------| --------------- | :---------: | ----------------------------------------------------- | @@ -1049,6 +1049,28 @@ With `auth_opt_jwt_parse_token` the signature would be `function checkAcl(token, Finally, this mode uses [otto](https://github.com/robertkrimen/otto) under the hood to run the scripts. Please check their documentation for supported features and known limitations. +#### Files mode + +When set to `files` JWT will run in Files mode, which allows to check user ACLs from a given file. +These ACLs follow the exact same syntax and semantics as those from the [Files](#files) backend. + +Options for this mode are: + +| Option | default | Mandatory | Meaning | +| ------------------------------| --------------- | :---------: | --------------------- | +| jwt_files_acl_path | | Y | Path to ACL files | + + +Notice there's no `passwords` file option since usernames come from parsing the JWT token and no password check is required. +Thus, you should be careful about general ACL rules and prefer to explicitly set rules for each valid user. + +If this shows to be a pain, I'm open to add a file that sets valid `users`, +i.e. like the `passwords` file for regular `Files` backend but without actual passwords. + +If you run into the case where you want to grant some general access but only to valid registered users, +and find that duplicating rules for each of them in ACLs file is really a pain, please open an issue for discussion. + + #### Password hashing Since JWT needs not to check passwords, there's no need to configure a `hasher`. diff --git a/backends/backends.go b/backends/backends.go index 6306227..a746f1a 100644 --- a/backends/backends.go +++ b/backends/backends.go @@ -257,12 +257,8 @@ func (b *Backends) setCheckers(authOpts map[string]string) error { } } - if len(b.userCheckers) == 0 { - return errors.New("no backend registered user checks") - } - - if len(b.aclCheckers) == 0 { - return errors.New("no backend registered ACL checks") + if len(b.userCheckers) == 0 && len(b.aclCheckers) == 0 { + return errors.New("no backends registered") } return nil @@ -270,31 +266,38 @@ func (b *Backends) setCheckers(authOpts map[string]string) error { // setPrefixes sets options for prefixes handling. func (b *Backends) setPrefixes(authOpts map[string]string, backends []string) { - if checkPrefix, ok := authOpts["check_prefix"]; ok && strings.Replace(checkPrefix, " ", "", -1) == "true" { - // Check that backends match prefixes. - if prefixesStr, ok := authOpts["prefixes"]; ok { - prefixes := strings.Split(strings.Replace(prefixesStr, " ", "", -1), ",") - if len(prefixes) == len(backends) { - // Set prefixes - // (I know some people find this type of comments useless, even harmful, - // but I find them helpful for quick code navigation on a project I don't work on daily, so screw them). - for i, backend := range backends { - b.prefixes[prefixes[i]] = backend - } - log.Infof("prefixes enabled for backends %s with prefixes %s.", authOpts["backends"], authOpts["prefixes"]) - b.checkPrefix = true - } else { - log.Errorf("Error: got %d backends and %d prefixes, defaulting to prefixes disabled.", len(backends), len(prefixes)) - b.checkPrefix = false - } + checkPrefix, ok := authOpts["check_prefix"] - } else { - log.Warn("Error: prefixes enabled but no options given, defaulting to prefixes disabled.") - b.checkPrefix = false - } - } else { + if !ok || strings.Replace(checkPrefix, " ", "", -1) != "true" { b.checkPrefix = false + + return } + + prefixesStr, ok := authOpts["prefixes"] + + if !ok { + log.Warn("Error: prefixes enabled but no options given, defaulting to prefixes disabled.") + b.checkPrefix = false + + return + } + + prefixes := strings.Split(strings.Replace(prefixesStr, " ", "", -1), ",") + + if len(prefixes) != len(backends) { + log.Errorf("Error: got %d backends and %d prefixes, defaulting to prefixes disabled.", len(backends), len(prefixes)) + b.checkPrefix = false + + return + } + + for i, backend := range backends { + b.prefixes[prefixes[i]] = backend + } + + log.Infof("prefixes enabled for backends %s with prefixes %s.", authOpts["backends"], authOpts["prefixes"]) + b.checkPrefix = true } // checkPrefix checks if a username contains a valid prefix. If so, returns ok and the suitable backend name; else, !ok and empty string. diff --git a/backends/backends_test.go b/backends/backends_test.go index 4e16d58..d638490 100644 --- a/backends/backends_test.go +++ b/backends/backends_test.go @@ -30,8 +30,8 @@ func TestBackends(t *testing.T) { pwPath, _ := filepath.Abs("../test-files/passwords") aclPath, _ := filepath.Abs("../test-files/acls") - authOpts["password_path"] = pwPath - authOpts["acl_path"] = aclPath + authOpts["files_password_path"] = pwPath + authOpts["files_acl_path"] = aclPath authOpts["redis_host"] = "localhost" authOpts["redis_port"] = "6379" @@ -65,24 +65,6 @@ func TestBackends(t *testing.T) { So(err.Error(), ShouldEqual, "unknown backend unknown") }) - Convey("On initialization, lacking user/acl checkers should result in an error", t, func() { - authOpts["backends"] = "files, redis" - authOpts["files_register"] = "user" - authOpts["redis_register"] = "user" - - _, err := Initialize(authOpts, log.DebugLevel) - So(err, ShouldNotBeNil) - So(err.Error(), ShouldEqual, "no backend registered ACL checks") - - authOpts["backends"] = "files, redis" - authOpts["files_register"] = "acl" - authOpts["redis_register"] = "acl" - - _, err = Initialize(authOpts, log.DebugLevel) - So(err, ShouldNotBeNil) - So(err.Error(), ShouldEqual, "no backend registered user checks") - }) - Convey("On initialization, unknown checkers should result in an error", t, func() { authOpts["backends"] = "files, redis" authOpts["files_register"] = "user" diff --git a/backends/constants.go b/backends/constants.go deleted file mode 100644 index 040db65..0000000 --- a/backends/constants.go +++ /dev/null @@ -1,12 +0,0 @@ -package backends - -//Mosquitto 1.5 introduces a new acc, MOSQ_ACL_SUBSCRIBE. Kept the names, so don't mind the linter. -//In almost any case, subscribe should be the same as read, except if you want to deny access to # by preventing it on subscribe. -const ( - MOSQ_ACL_NONE = 0x00 - MOSQ_ACL_READ = 0x01 - MOSQ_ACL_WRITE = 0x02 - MOSQ_ACL_READWRITE = 0x03 - MOSQ_ACL_SUBSCRIBE = 0x04 - MOSQ_ACL_DENY = 0x11 -) diff --git a/backends/constants/constants.go b/backends/constants/constants.go new file mode 100644 index 0000000..e0f8891 --- /dev/null +++ b/backends/constants/constants.go @@ -0,0 +1,12 @@ +package constants + +// Mosquitto 1.5 introduces a new acc, MOSQ_ACL_SUBSCRIBE. Kept the names, so don't mind the linter. +// In almost any case, subscribe should be the same as read, except if you want to deny access to # by preventing it on subscribe. +const ( + MOSQ_ACL_NONE = 0x00 + MOSQ_ACL_READ = 0x01 + MOSQ_ACL_WRITE = 0x02 + MOSQ_ACL_READWRITE = 0x03 + MOSQ_ACL_SUBSCRIBE = 0x04 + MOSQ_ACL_DENY = 0x11 +) diff --git a/backends/custom_plugin.go b/backends/custom_plugin.go index 98274bd..5204e59 100644 --- a/backends/custom_plugin.go +++ b/backends/custom_plugin.go @@ -20,7 +20,7 @@ type CustomPlugin struct { func NewCustomPlugin(authOpts map[string]string, logLevel log.Level) (*CustomPlugin, error) { plug, err := plugin.Open(authOpts["plugin_path"]) if err != nil { - return nil, fmt.Errorf("Could not init custom plugin: %s", err) + return nil, fmt.Errorf("could not init custom plugin: %s", err) } customPlugin := &CustomPlugin{ @@ -31,14 +31,14 @@ func NewCustomPlugin(authOpts map[string]string, logLevel log.Level) (*CustomPlu plInit, err := plug.Lookup("Init") if err != nil { - return nil, fmt.Errorf("Couldn't find func Init in plugin: %s", err) + return nil, fmt.Errorf("couldn't find func Init in plugin: %s", err) } initFunc := plInit.(func(authOpts map[string]string, logLevel log.Level) error) err = initFunc(authOpts, logLevel) if err != nil { - return nil, fmt.Errorf("Couldn't init plugin: %s", err) + return nil, fmt.Errorf("couldn't init plugin: %s", err) } customPlugin.init = initFunc @@ -46,7 +46,7 @@ func NewCustomPlugin(authOpts map[string]string, logLevel log.Level) (*CustomPlu plName, err := plug.Lookup("GetName") if err != nil { - return nil, fmt.Errorf("Couldn't find func GetName in plugin: %s", err) + return nil, fmt.Errorf("couldn't find func GetName in plugin: %s", err) } nameFunc := plName.(func() string) @@ -101,7 +101,7 @@ func NewCustomPlugin(authOpts map[string]string, logLevel log.Level) (*CustomPlu plHalt, err := plug.Lookup("Halt") if err != nil { - return nil, fmt.Errorf("Couldn't find func Halt in plugin: %s", err) + return nil, fmt.Errorf("couldn't find func Halt in plugin: %s", err) } haltFunc := plHalt.(func()) diff --git a/backends/files.go b/backends/files.go index 34f2855..c012c6c 100644 --- a/backends/files.go +++ b/backends/files.go @@ -1,422 +1,69 @@ package backends import ( - "bufio" - "fmt" - "os" - "os/signal" "strings" - "sync" - "syscall" + "github.com/iegomez/mosquitto-go-auth/backends/files" "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) -const ( - read = "read" - write = "write" - readwrite = "readwrite" - subscribe = "subscribe" - deny = "deny" -) - -var permissions = map[string]byte{ - read: MOSQ_ACL_READ, - write: MOSQ_ACL_WRITE, - readwrite: MOSQ_ACL_READWRITE, - subscribe: MOSQ_ACL_SUBSCRIBE, - deny: MOSQ_ACL_DENY, -} - -//FileUer keeps a user password and acl records. -type FileUser struct { - Password string - AclRecords []AclRecord -} - -//AclRecord holds a topic and access privileges. -type AclRecord struct { - Topic string - Acc byte //None 0x00, Read 0x01, Write 0x02, ReadWrite: Read | Write : 0x03, Subscribe 0x04, Deny 0x11 -} - -//FileBE holds paths to files, list of file users and general (no user or pattern) acl records. +// Files hols a static failes checker. type Files struct { - sync.Mutex - PasswordPath string - AclPath string - CheckAcls bool - Users map[string]*FileUser //Users keeps a registry of username/FileUser pairs, holding a user's password and Acl records. - AclRecords []AclRecord - filesOnly bool - hasher hashing.HashComparer - signals chan os.Signal + checker *files.Checker } -//NewFiles initializes a files backend. +// NewFiles initializes a files backend. func NewFiles(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (*Files, error) { log.SetLevel(logLevel) - var files = &Files{ - PasswordPath: "", - AclPath: "", - CheckAcls: false, - Users: make(map[string]*FileUser), - AclRecords: make([]AclRecord, 0), - filesOnly: true, - hasher: hasher, - signals: make(chan os.Signal, 1), + /* + It is an error for the Files backend not to have a passwords file, but it is not for the underlying + static files checker since it may be used in JWT. Thus, we need to check for the option here before + building our checker. + */ + + pwRegistered := strings.Contains(authOpts["files_register"], "user") + + pwPath, ok := authOpts["files_password_path"] + + if pwRegistered && (!ok || pwPath == "") { + return nil, errors.New("missing passwords file path") } - if len(strings.Split(strings.Replace(authOpts["backends"], " ", "", -1), ",")) > 1 { - files.filesOnly = false - } - - if passwordPath, ok := authOpts["password_path"]; ok { - files.PasswordPath = passwordPath - } else { - return nil, errors.New("Files backend error: no password path given") - } - - if aclPath, ok := authOpts["acl_path"]; ok { - files.AclPath = aclPath - files.CheckAcls = true - } else { - files.CheckAcls = false - log.Info("Acls won't be checked") - } - - err := files.loadFiles() + var checker, err = files.NewChecker(authOpts["backends"], authOpts["files_password_path"], authOpts["files_acl_path"], logLevel, hasher) if err != nil { return nil, err } - go files.watchSignals() - - return files, nil + return &Files{ + checker: checker, + }, nil } -func (o *Files) watchSignals() { - signal.Notify(o.signals, syscall.SIGHUP) - - for { - select { - case sig := <-o.signals: - if sig == syscall.SIGHUP { - log.Debugln("Got SIGHUP, reloading files.") - o.loadFiles() - } - } - } -} - -func (o *Files) loadFiles() error { - o.Lock() - defer o.Unlock() - - count, err := o.readPasswords() - if err != nil { - return errors.Errorf("read passwords: %s", err) - } - - log.Debugf("got %d users from passwords file", count) - - //Only read acls if path was given. - if o.CheckAcls { - count, err := o.readAcls() - if err != nil { - return errors.Errorf("read acls: %s", err) - } - - log.Debugf("got %d lines from acl file", count) - } - - return nil -} - -//ReadPasswords read file and populates FileUsers. Return amount of users seen and possile error. -func (o *Files) readPasswords() (int, error) { - - usersCount := 0 - - file, err := os.Open(o.PasswordPath) - if err != nil { - return usersCount, fmt.Errorf("Files backend error: couldn't open passwords file: %s", err) - } - defer file.Close() - scanner := bufio.NewScanner(file) - scanner.Split(bufio.ScanLines) - - index := 0 - //Read line by line - for scanner.Scan() { - index++ - - //Check comment or empty line to skip them. - if checkCommentOrEmpty(scanner.Text()) { - continue - } - - lineArr := strings.Split(scanner.Text(), ":") - if len(lineArr) != 2 { - log.Errorf("Read passwords error: line %d is not well formatted", index) - continue - } - //Create user if it doesn't exist and save password; override password if user existed. - var fileUser *FileUser - var ok bool - fileUser, ok = o.Users[lineArr[0]] - if ok { - fileUser.Password = lineArr[1] - } else { - usersCount++ - fileUser = &FileUser{ - Password: lineArr[1], - AclRecords: make([]AclRecord, 0), - } - o.Users[lineArr[0]] = fileUser - } - } - - return usersCount, nil - -} - -// ReadAcls reads the Acl file and associates them to existing users. It omits any non existing users. -func (o *Files) readAcls() (int, error) { - linesCount := 0 - currentUser := "" - userExists := false - - file, err := os.Open(o.AclPath) - if err != nil { - return linesCount, errors.Errorf("Files backend error: couldn't open acl file: %s", err) - } - defer file.Close() - scanner := bufio.NewScanner(file) - scanner.Split(bufio.ScanLines) - - index := 0 - - for scanner.Scan() { - index++ - - if checkCommentOrEmpty(scanner.Text()) { - continue - } - - line := strings.TrimSpace(scanner.Text()) - - lineArr := strings.Fields(line) - prefix := lineArr[0] - - if prefix == "user" { - // Since there may be more than one consecutive space in the username, we have to remove the prefix and trim to get the username. - username, err := removeAndTrim(prefix, line, index) - if err != nil { - return 0, err - } - - _, ok := o.Users[username] - - if !ok { - log.Warnf("user %s doesn't exist, skipping acls", username) - // Flag username to skip topics later. - userExists = false - continue - } - - userExists = true - currentUser = username - } else if prefix == "topic" || prefix == "pattern" { - var aclRecord = AclRecord{ - Topic: "", - Acc: MOSQ_ACL_NONE, - } - - /* If len is 2, then we assume ReadWrite privileges. - - Notice that Mosquitto docs prevent whitespaces in the topic when there's no explicit access given: - "The access type is controlled using "read", "write", "readwrite" or "deny". This parameter is optional (unless includes a space character)" - https://mosquitto.org/man/mosquitto-conf-5.html - When access is given, then the topic may contain whitespaces. - - Nevertheless, there may be white spaces between topic/pattern and the permission or the topic itself. - Fields captures the case in which there's only topic/pattern and the given topic because it trims extra spaces between them. - */ - if len(lineArr) == 2 { - aclRecord.Topic = lineArr[1] - aclRecord.Acc = MOSQ_ACL_READWRITE - } else { - // There may be more than one space between topic/pattern and the permission, as well as between the latter and the topic itself. - // Hence, we remove the prefix, trim the line and split on white space to get the permission. - line, err = removeAndTrim(prefix, line, index) - if err != nil { - return 0, err - } - - lineArr = strings.Split(line, " ") - permission := lineArr[0] - - // Again, there may be more than one space between the permission and the topic, so we'll trim what's left after removing it and that'll be the topic. - topic, err := removeAndTrim(permission, line, index) - if err != nil { - return 0, err - } - - switch permission { - case read, write, readwrite, subscribe, deny: - aclRecord.Acc = permissions[permission] - default: - return 0, errors.Errorf("Files backend error: wrong acl format at line %d", index) - } - - aclRecord.Topic = topic - } - - if prefix == "topic" { - if currentUser != "" { - // Skip topic when user was not found. - if !userExists { - continue - } - - fUser, ok := o.Users[currentUser] - if !ok { - return 0, errors.Errorf("Files backend error: user does not exist for acl at line %d", index) - } - fUser.AclRecords = append(fUser.AclRecords, aclRecord) - } else { - o.AclRecords = append(o.AclRecords, aclRecord) - } - } else { - o.AclRecords = append(o.AclRecords, aclRecord) - } - - linesCount++ - - } else { - return 0, errors.Errorf("Files backend error: wrong acl format at line %d", index) - } - } - - return linesCount, nil -} - -func removeAndTrim(prefix, line string, index int) (string, error) { - if len(line)-len(prefix) < 1 { - return "", errors.Errorf("Files backend error: wrong acl format at line %d", index) - } - newLine := strings.TrimSpace(line[len(prefix):]) - - return newLine, nil -} - -func checkCommentOrEmpty(line string) bool { - if len(strings.Replace(line, " ", "", -1)) == 0 || line[0:1] == "#" { - return true - } - return false -} - -//GetUser checks that user exists and password is correct. +// GetUser checks that user exists and password is correct. func (o *Files) GetUser(username, password, clientid string) (bool, error) { - - fileUser, ok := o.Users[username] - if !ok { - return false, nil - } - - if o.hasher.Compare(password, fileUser.Password) { - return true, nil - } - - log.Warnf("wrong password for user %s", username) - - return false, nil - + return o.checker.GetUser(username, password, clientid) } -//GetSuperuser returns false for files backend. +// GetSuperuser returns false for files backend. func (o *Files) GetSuperuser(username string) (bool, error) { return false, nil } -//CheckAcl checks that the topic may be read/written by the given user/clientid. +// CheckAcl checks that the topic may be read/written by the given user/clientid. func (o *Files) CheckAcl(username, topic, clientid string, acc int32) (bool, error) { - //If there are no acls and Files is the only backend, all access is allowed. - //If there are other backends, then we can't blindly grant access. - if !o.CheckAcls { - return o.filesOnly, nil - } - - fileUser, ok := o.Users[username] - - // Check if the topic was explicitly denied and refuse to authorize if so. - if ok { - for _, aclRecord := range fileUser.AclRecords { - match := TopicsMatch(aclRecord.Topic, topic) - - if match { - if aclRecord.Acc == MOSQ_ACL_DENY { - return false, nil - } - } - } - } - - for _, aclRecord := range o.AclRecords { - aclTopic := strings.Replace(aclRecord.Topic, "%c", clientid, -1) - aclTopic = strings.Replace(aclTopic, "%u", username, -1) - - match := TopicsMatch(aclTopic, topic) - - if match { - if aclRecord.Acc == MOSQ_ACL_DENY { - return false, nil - } - } - } - - // No denials, check against user's acls and common ones. If not authorized, check against pattern acls. - if ok { - for _, aclRecord := range fileUser.AclRecords { - match := TopicsMatch(aclRecord.Topic, topic) - - if match { - if acc == int32(aclRecord.Acc) || int32(aclRecord.Acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.Acc) == MOSQ_ACL_READ || int32(aclRecord.Acc) == MOSQ_ACL_SUBSCRIBE)) { - return true, nil - } - } - } - } - for _, aclRecord := range o.AclRecords { - //Replace all occurrences of %c for clientid and %u for username - aclTopic := strings.Replace(aclRecord.Topic, "%c", clientid, -1) - aclTopic = strings.Replace(aclTopic, "%u", username, -1) - - match := TopicsMatch(aclTopic, topic) - - if match { - if acc == int32(aclRecord.Acc) || int32(aclRecord.Acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.Acc) == MOSQ_ACL_READ || int32(aclRecord.Acc) == MOSQ_ACL_SUBSCRIBE)) { - return true, nil - } - } - } - - return false, nil - + return o.checker.CheckAcl(username, topic, clientid, acc) } -//GetName returns the backend's name +// GetName returns the backend's name func (o *Files) GetName() string { return "Files" } -//Halt does nothing for files as there's no cleanup needed. +// Halt cleans up Files backend. func (o *Files) Halt() { - //Do nothing + o.checker.Halt() } diff --git a/backends/files/files.go b/backends/files/files.go new file mode 100644 index 0000000..432326f --- /dev/null +++ b/backends/files/files.go @@ -0,0 +1,438 @@ +package files + +import ( + "bufio" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + + . "github.com/iegomez/mosquitto-go-auth/backends/constants" + "github.com/iegomez/mosquitto-go-auth/backends/topics" + "github.com/iegomez/mosquitto-go-auth/hashing" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +const ( + read = "read" + write = "write" + readwrite = "readwrite" + subscribe = "subscribe" + deny = "deny" +) + +var permissions = map[string]byte{ + read: MOSQ_ACL_READ, + write: MOSQ_ACL_WRITE, + readwrite: MOSQ_ACL_READWRITE, + subscribe: MOSQ_ACL_SUBSCRIBE, + deny: MOSQ_ACL_DENY, +} + +// StaticFileUer keeps a user password and acl records. +type staticFileUser struct { + password string + aclRecords []aclRecord +} + +// aclRecord holds a topic and access privileges. +type aclRecord struct { + topic string + acc byte //None 0x00, Read 0x01, Write 0x02, ReadWrite: Read | Write : 0x03, Subscribe 0x04, Deny 0x11 +} + +// Checker holds paths to static files, list of file users and general (no user or pattern) acl records. +type Checker struct { + sync.Mutex + pwPath string + aclPath string + checkACLs bool + checkUsers bool + users map[string]*staticFileUser //users keeps a registry of username/staticFileUser pairs, holding a user's password and Acl records. + aclRecords []aclRecord + staticFilesOnly bool + hasher hashing.HashComparer + signals chan os.Signal +} + +// NewCheckers initializes a static files checker. +func NewChecker(backends, passwordPath, aclPath string, logLevel log.Level, hasher hashing.HashComparer) (*Checker, error) { + + log.SetLevel(logLevel) + + var checker = &Checker{ + pwPath: passwordPath, + aclPath: aclPath, + checkACLs: true, + users: make(map[string]*staticFileUser), + aclRecords: make([]aclRecord, 0), + staticFilesOnly: true, + hasher: hasher, + signals: make(chan os.Signal, 1), + checkUsers: true, + } + + if checker.pwPath == "" { + checker.checkUsers = false + log.Infoln("[StaticFiles] passwords won't be checked") + } + + if checker.aclPath == "" { + checker.checkACLs = false + log.Infoln("[StaticFiles] acls won't be checked") + } + + if len(strings.Split(strings.Replace(backends, " ", "", -1), ",")) > 1 { + checker.staticFilesOnly = false + } + + err := checker.loadStaticFiles() + if err != nil { + return nil, err + } + + go checker.watchSignals() + + return checker, nil +} + +func (o *Checker) watchSignals() { + signal.Notify(o.signals, syscall.SIGHUP) + + for { + select { + case sig := <-o.signals: + if sig == syscall.SIGHUP { + log.Debugln("[StaticFiles] got SIGHUP, reloading static files") + o.loadStaticFiles() + } + } + } +} + +func (o *Checker) loadStaticFiles() error { + o.Lock() + defer o.Unlock() + + if o.checkUsers { + count, err := o.readPasswords() + if err != nil { + return errors.Errorf("read passwords: %s", err) + } + + log.Debugf("got %d users from passwords file", count) + } + + if o.checkACLs { + count, err := o.readAcls() + if err != nil { + return errors.Errorf("read acls: %s", err) + } + + log.Debugf("got %d lines from acl file", count) + } + + return nil +} + +// ReadPasswords reads passwords file and populates static file users. Returns amount of users seen and possile error. +func (o *Checker) readPasswords() (int, error) { + + usersCount := 0 + + file, err := os.Open(o.pwPath) + if err != nil { + return usersCount, fmt.Errorf("[StaticFiles] error: couldn't open passwords file: %s", err) + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Split(bufio.ScanLines) + + index := 0 + for scanner.Scan() { + index++ + + text := scanner.Text() + + if checkCommentOrEmpty(text) { + continue + } + + lineArr := strings.Split(text, ":") + if len(lineArr) != 2 { + log.Errorf("Read passwords error: line %d is not well formatted", index) + continue + } + + var fileUser *staticFileUser + var ok bool + fileUser, ok = o.users[lineArr[0]] + if ok { + fileUser.password = lineArr[1] + } else { + usersCount++ + fileUser = &staticFileUser{ + password: lineArr[1], + aclRecords: make([]aclRecord, 0), + } + o.users[lineArr[0]] = fileUser + } + } + + return usersCount, nil + +} + +// readAcls reads the Acl file and associates them to existing users. It omits any non existing users. +func (o *Checker) readAcls() (int, error) { + linesCount := 0 + currentUser := "" + userExists := false + userSeen := false + + file, err := os.Open(o.aclPath) + if err != nil { + return linesCount, errors.Errorf("StaticFiles backend error: couldn't open acl file: %s", err) + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Split(bufio.ScanLines) + + index := 0 + + for scanner.Scan() { + index++ + + text := scanner.Text() + + if checkCommentOrEmpty(text) { + continue + } + + line := strings.TrimSpace(text) + + lineArr := strings.Fields(line) + prefix := lineArr[0] + + if prefix == "user" { + // Flag that a user has been seen so no topic coming after is addigned to general ones. + userSeen = true + + // Since there may be more than one consecutive space in the username, we have to remove the prefix and trim to get the username. + username, err := removeAndTrim(prefix, line, index) + if err != nil { + return 0, err + } + + _, ok := o.users[username] + + if !ok { + if o.checkUsers { + log.Warnf("user %s doesn't exist, skipping acls", username) + // Flag username to skip topics later. + userExists = false + continue + } + + o.users[username] = &staticFileUser{ + password: "", + aclRecords: make([]aclRecord, 0), + } + } + + userExists = true + currentUser = username + } else if prefix == "topic" || prefix == "pattern" { + var aclRecord = aclRecord{ + topic: "", + acc: MOSQ_ACL_NONE, + } + + /* If len is 2, then we assume ReadWrite privileges. + + Notice that Mosquitto docs prevent whitespaces in the topic when there's no explicit access given: + "The access type is controlled using "read", "write", "readwrite" or "deny". This parameter is optional (unless includes a space character)" + https://mosquitto.org/man/mosquitto-conf-5.html + When access is given, then the topic may contain whitespaces. + + Nevertheless, there may be white spaces between topic/pattern and the permission or the topic itself. + Fields captures the case in which there's only topic/pattern and the given topic because it trims extra spaces between them. + */ + if len(lineArr) == 2 { + aclRecord.topic = lineArr[1] + aclRecord.acc = MOSQ_ACL_READWRITE + } else { + // There may be more than one space between topic/pattern and the permission, as well as between the latter and the topic itself. + // Hence, we remove the prefix, trim the line and split on white space to get the permission. + line, err = removeAndTrim(prefix, line, index) + if err != nil { + return 0, err + } + + lineArr = strings.Split(line, " ") + permission := lineArr[0] + + // Again, there may be more than one space between the permission and the topic, so we'll trim what's left after removing it and that'll be the topic. + topic, err := removeAndTrim(permission, line, index) + if err != nil { + return 0, err + } + + switch permission { + case read, write, readwrite, subscribe, deny: + aclRecord.acc = permissions[permission] + default: + return 0, errors.Errorf("StaticFiles backend error: wrong acl format at line %d", index) + } + + aclRecord.topic = topic + } + + if prefix == "topic" { + if currentUser != "" { + // Skip topic when user was not found. + if !userExists { + continue + } + + fUser, ok := o.users[currentUser] + if !ok { + return 0, errors.Errorf("StaticFiles backend error: user does not exist for acl at line %d", index) + } + fUser.aclRecords = append(fUser.aclRecords, aclRecord) + } else { + // Only append to general topics when no user has been processed. + if !userSeen { + o.aclRecords = append(o.aclRecords, aclRecord) + } + } + } else { + o.aclRecords = append(o.aclRecords, aclRecord) + } + + linesCount++ + + } else { + return 0, errors.Errorf("StaticFiles backend error: wrong acl format at line %d", index) + } + } + + return linesCount, nil +} + +func removeAndTrim(prefix, line string, index int) (string, error) { + if len(line)-len(prefix) < 1 { + return "", errors.Errorf("StaticFiles backend error: wrong acl format at line %d", index) + } + newLine := strings.TrimSpace(line[len(prefix):]) + + return newLine, nil +} + +func checkCommentOrEmpty(line string) bool { + if len(strings.Replace(line, " ", "", -1)) == 0 || line[0:1] == "#" { + return true + } + return false +} + +func (o *Checker) Users() map[string]*staticFileUser { + return o.users +} + +// GetUser checks that user exists and password is correct. +func (o *Checker) GetUser(username, password, clientid string) (bool, error) { + + fileUser, ok := o.users[username] + if !ok { + return false, nil + } + + if o.hasher.Compare(password, fileUser.password) { + return true, nil + } + + log.Warnf("wrong password for user %s", username) + + return false, nil + +} + +// GetSuperuser returns false as there are no files superusers. +func (o *Checker) GetSuperuser(username string) (bool, error) { + return false, nil +} + +// CheckAcl checks that the topic may be read/written by the given user/clientid. +func (o *Checker) CheckAcl(username, topic, clientid string, acc int32) (bool, error) { + // If there are no acls and StaticFiles is the only backend, all access is allowed. + // If there are other backends, then we can't blindly grant access. + if !o.checkACLs { + return o.staticFilesOnly, nil + } + + fileUser, ok := o.users[username] + + // Check if the topic was explicitly denied and refuse to authorize if so. + if ok { + for _, aclRecord := range fileUser.aclRecords { + match := topics.Match(aclRecord.topic, topic) + + if match { + if aclRecord.acc == MOSQ_ACL_DENY { + return false, nil + } + } + } + } + + for _, aclRecord := range o.aclRecords { + aclTopic := strings.Replace(aclRecord.topic, "%c", clientid, -1) + aclTopic = strings.Replace(aclTopic, "%u", username, -1) + + match := topics.Match(aclTopic, topic) + + if match { + if aclRecord.acc == MOSQ_ACL_DENY { + return false, nil + } + } + } + + // No denials, check against user's acls and common ones. If not authorized, check against pattern acls. + if ok { + for _, aclRecord := range fileUser.aclRecords { + match := topics.Match(aclRecord.topic, topic) + + if match { + if acc == int32(aclRecord.acc) || int32(aclRecord.acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.acc) == MOSQ_ACL_READ || int32(aclRecord.acc) == MOSQ_ACL_SUBSCRIBE)) { + return true, nil + } + } + } + } + for _, aclRecord := range o.aclRecords { + // Replace all occurrences of %c for clientid and %u for username + aclTopic := strings.Replace(aclRecord.topic, "%c", clientid, -1) + aclTopic = strings.Replace(aclTopic, "%u", username, -1) + + match := topics.Match(aclTopic, topic) + + if match { + if acc == int32(aclRecord.acc) || int32(aclRecord.acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.acc) == MOSQ_ACL_READ || int32(aclRecord.acc) == MOSQ_ACL_SUBSCRIBE)) { + return true, nil + } + } + } + + return false, nil + +} + +// Halt does nothing for static files as there's no cleanup needed. +func (o *Checker) Halt() { + // NO-OP +} diff --git a/backends/files/files_test.go b/backends/files/files_test.go new file mode 100644 index 0000000..15cd9c8 --- /dev/null +++ b/backends/files/files_test.go @@ -0,0 +1,372 @@ +package files + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + . "github.com/iegomez/mosquitto-go-auth/backends/constants" + "github.com/iegomez/mosquitto-go-auth/hashing" + log "github.com/sirupsen/logrus" + . "github.com/smartystreets/goconvey/convey" +) + +func TestFiles(t *testing.T) { + authOpts := make(map[string]string) + + Convey("Given empty opts NewChecker should fail", t, func() { + files, err := NewChecker("", "", "", log.DebugLevel, hashing.NewHasher(authOpts, "files")) + So(err, ShouldBeError) + + files.Halt() + }) + + Convey("Given valid params NewChecker should return a new checker instance", t, func() { + backendsOpt := "files" + pwPath, err := filepath.Abs("test-files/passwords") + So(err, ShouldBeNil) + aclPath, err := filepath.Abs("test-files/acls") + So(err, ShouldBeNil) + clientID := "test_client" + + files, err := NewChecker(backendsOpt, pwPath, aclPath, log.DebugLevel, hashing.NewHasher(authOpts, "files")) + So(err, ShouldBeNil) + + /* + ACL file looks like this: + + topic test/general + topic deny test/general_denied + + user test1 + topic write test/topic/1 + topic read test/topic/2 + + user test2 + topic read test/topic/+ + + user test3 + topic read test/# + topic deny test/denied + + user test with space + topic test/space + topic read test/multiple spaces in/topic + topic read test/lots of spaces in/topic and borders + + user not_present + topic read test/not_present + + pattern read test/%u + pattern read test/%c + */ + + // passwords are the same as users, + // except for user4 that's not present in passwords and should be skipped when reading acls + user1 := "test1" + user2 := "test2" + user3 := "test3" + user4 := "not_present" + elton := "test with space" // You know, because he's a rocket man. Ok, I'll let myself out. + + generalTopic := "test/general" + generalDeniedTopic := "test/general_denied" + + Convey("All users but not present ones should have a record", func() { + _, ok := files.users[user1] + So(ok, ShouldBeTrue) + + _, ok = files.users[user2] + So(ok, ShouldBeTrue) + + _, ok = files.users[user3] + So(ok, ShouldBeTrue) + + _, ok = files.users[user4] + So(ok, ShouldBeFalse) + + _, ok = files.users[elton] + So(ok, ShouldBeTrue) + }) + + Convey("All users should be able to read the general topic", func() { + authenticated, err := files.CheckAcl(user1, generalTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + + authenticated, err = files.CheckAcl(user2, generalTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + + authenticated, err = files.CheckAcl(user3, generalTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + + authenticated, err = files.CheckAcl(elton, generalTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + }) + + Convey("No user should be able to read the general denied topic", func() { + authenticated, err := files.CheckAcl(user1, generalDeniedTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeFalse) + + authenticated, err = files.CheckAcl(user2, generalDeniedTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeFalse) + + authenticated, err = files.CheckAcl(user3, generalDeniedTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeFalse) + + authenticated, err = files.CheckAcl(elton, generalDeniedTopic, clientID, 1) + So(err, ShouldBeNil) + So(authenticated, ShouldBeFalse) + }) + + Convey("Given a username and a correct password, it should correctly authenticate it", func() { + authenticated, err := files.GetUser(user1, user1, clientID) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + + authenticated, err = files.GetUser(user2, user2, clientID) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + + authenticated, err = files.GetUser(user3, user3, clientID) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + + authenticated, err = files.GetUser(elton, elton, clientID) + So(err, ShouldBeNil) + So(authenticated, ShouldBeTrue) + }) + + Convey("Given a username and an incorrect password, it should not authenticate it", func() { + authenticated, err := files.GetUser(user1, user2, clientID) + So(err, ShouldBeNil) + So(authenticated, ShouldBeFalse) + }) + + Convey("Given a wrong username, it should not authenticate it and not return error", func() { + authenticated, err := files.GetUser(user4, "whatever_password", "") + So(err, ShouldBeNil) + So(authenticated, ShouldBeFalse) + }) + + //There are no superusers for files + Convey("For any user superuser should return false", func() { + superuser, err := files.GetSuperuser(user1) + So(err, ShouldBeNil) + So(superuser, ShouldBeFalse) + + Convey("Including non-present username", func() { + superuser, err := files.GetSuperuser(user4) + So(err, ShouldBeNil) + So(superuser, ShouldBeFalse) + }) + }) + + testTopic1 := `test/topic/1` + testTopic2 := `test/topic/2` + testTopic3 := `test/other/1` + testTopic4 := `other/1` + readWriteTopic := "readwrite/topic" + spaceTopic := "test/space" + multiSpaceTopic := "test/multiple spaces in/topic" + lotsOfSpacesTopic := "test/lots of spaces in/topic and borders" + deniedTopic := "test/denied" + + Convey("Topics for non existing users should be ignored when there's a passwords file", func() { + for record := range files.aclRecords { + So(record, ShouldNotEqual, "test/not_present") + } + + for _, user := range files.users { + for record := range user.aclRecords { + So(record, ShouldNotEqual, "test/not_present") + } + } + }) + + Convey("Topics for users should be honored when there's no passwords file", func() { + tt, err := files.CheckAcl("not_present", "test/not_present", clientID, 1) + + So(err, ShouldBeNil) + So(tt, ShouldBeTrue) + }) + + Convey("User 1 should be able to publish and not subscribe to test topic 1, and only subscribe but not publish to topic 2", func() { + tt1, err1 := files.CheckAcl(user1, testTopic1, clientID, 2) + tt2, err2 := files.CheckAcl(user1, testTopic1, clientID, 1) + tt3, err3 := files.CheckAcl(user1, testTopic2, clientID, 2) + tt4, err4 := files.CheckAcl(user1, testTopic2, clientID, 1) + + So(err1, ShouldBeNil) + So(err2, ShouldBeNil) + So(err3, ShouldBeNil) + So(err4, ShouldBeNil) + So(tt1, ShouldBeTrue) + So(tt2, ShouldBeFalse) + So(tt3, ShouldBeFalse) + So(tt4, ShouldBeTrue) + }) + + Convey("User 1 should be able to subscribe or publish to a readwrite topic rule", func() { + tt1, err1 := files.CheckAcl(user1, readWriteTopic, clientID, 2) + tt2, err2 := files.CheckAcl(user1, readWriteTopic, clientID, 1) + So(err1, ShouldBeNil) + So(err2, ShouldBeNil) + So(tt1, ShouldBeTrue) + So(tt2, ShouldBeTrue) + }) + + Convey("User 2 should be able to read any test/topic/X but not any/other", func() { + tt1, err1 := files.CheckAcl(user2, testTopic1, clientID, 1) + tt2, err2 := files.CheckAcl(user2, testTopic2, clientID, 1) + tt3, err3 := files.CheckAcl(user2, testTopic3, clientID, 1) + + So(err1, ShouldBeNil) + So(err2, ShouldBeNil) + So(err3, ShouldBeNil) + So(tt1, ShouldBeTrue) + So(tt2, ShouldBeTrue) + So(tt3, ShouldBeFalse) + }) + + Convey("User 3 should be able to read any test/X but not other/... nor test/denied\n\n", func() { + tt1, err1 := files.CheckAcl(user3, testTopic1, clientID, 1) + tt2, err2 := files.CheckAcl(user3, testTopic2, clientID, 1) + tt3, err3 := files.CheckAcl(user3, testTopic3, clientID, 1) + tt4, err4 := files.CheckAcl(user3, testTopic4, clientID, 1) + tt5, err5 := files.CheckAcl(user3, deniedTopic, clientID, 1) + + So(err1, ShouldBeNil) + So(err2, ShouldBeNil) + So(err3, ShouldBeNil) + So(err4, ShouldBeNil) + So(err5, ShouldBeNil) + So(tt1, ShouldBeTrue) + So(tt2, ShouldBeTrue) + So(tt3, ShouldBeTrue) + So(tt4, ShouldBeFalse) + So(tt5, ShouldBeFalse) + }) + + Convey("User 4 should not be able to read since it's not in the passwords file", func() { + tt1, err1 := files.CheckAcl(user4, testTopic1, clientID, 1) + + So(err1, ShouldBeNil) + So(tt1, ShouldBeFalse) + }) + + Convey("Elton Bowie should be able to read and write to `test/space`, and only read from other topics", func() { + tt1, err1 := files.CheckAcl(elton, spaceTopic, clientID, 2) + tt2, err2 := files.CheckAcl(elton, multiSpaceTopic, clientID, 1) + tt3, err3 := files.CheckAcl(elton, multiSpaceTopic, clientID, 2) + tt4, err4 := files.CheckAcl(elton, lotsOfSpacesTopic, clientID, 1) + tt5, err5 := files.CheckAcl(elton, lotsOfSpacesTopic, clientID, 2) + + So(err1, ShouldBeNil) + So(err2, ShouldBeNil) + So(err3, ShouldBeNil) + So(err4, ShouldBeNil) + So(err5, ShouldBeNil) + So(tt1, ShouldBeTrue) + So(tt2, ShouldBeTrue) + So(tt3, ShouldBeFalse) + So(tt4, ShouldBeTrue) + So(tt5, ShouldBeFalse) + }) + + //Now check against patterns. + Convey("Given a topic that mentions username, acl check should pass", func() { + tt1, err1 := files.CheckAcl(user1, "test/test1", clientID, 1) + So(err1, ShouldBeNil) + So(tt1, ShouldBeTrue) + + tt2, err2 := files.CheckAcl(elton, "test/test with space", clientID, 1) + So(err2, ShouldBeNil) + So(tt2, ShouldBeTrue) + }) + + Convey("Given a topic that mentions clientid, acl check should pass", func() { + tt1, err1 := files.CheckAcl(user1, "test/test_client", clientID, 1) + So(err1, ShouldBeNil) + So(tt1, ShouldBeTrue) + }) + + //Halt files + files.Halt() + }) + + Convey("On SIGHUP files should be reloaded", t, func() { + pwFile, err := os.Create("test-files/test-passwords") + So(err, ShouldBeNil) + aclFile, err := os.Create("test-files/test-acls") + So(err, ShouldBeNil) + + pwPath, err := filepath.Abs("test-files/test-passwords") + So(err, ShouldBeNil) + aclPath, err := filepath.Abs("test-files/test-acls") + So(err, ShouldBeNil) + + defer os.Remove(pwPath) + defer os.Remove(aclPath) + + hasher := hashing.NewHasher(authOpts, "files") + + user1 := "test1" + user2 := "test2" + + pw1, err := hasher.Hash(user1) + So(err, ShouldBeNil) + + pw2, err := hasher.Hash(user2) + So(err, ShouldBeNil) + + pwFile.WriteString(fmt.Sprintf("\n%s:%s\n", user1, pw1)) + + aclFile.WriteString("\nuser test1") + aclFile.WriteString("\ntopic read test/#") + + pwFile.Sync() + aclFile.Sync() + + backendsOpt := "files" + + files, err := NewChecker(backendsOpt, pwPath, aclPath, log.DebugLevel, hasher) + So(err, ShouldBeNil) + + user, ok := files.users[user1] + So(ok, ShouldBeTrue) + + record := user.aclRecords[0] + So(record.acc, ShouldEqual, MOSQ_ACL_READ) + So(record.topic, ShouldEqual, "test/#") + + _, ok = files.users[user2] + So(ok, ShouldBeFalse) + + // Now add second user and reload. + pwFile.WriteString(fmt.Sprintf("\n%s:%s\n", user2, pw2)) + + aclFile.WriteString("\nuser test2") + aclFile.WriteString("\ntopic write test/#") + + files.signals <- syscall.SIGHUP + + time.Sleep(200 * time.Millisecond) + + user, ok = files.users[user2] + So(ok, ShouldBeTrue) + + record = user.aclRecords[0] + So(record.acc, ShouldEqual, MOSQ_ACL_WRITE) + So(record.topic, ShouldEqual, "test/#") + }) +} diff --git a/backends/files_test.go b/backends/files_test.go index be2c2a2..2c82942 100644 --- a/backends/files_test.go +++ b/backends/files_test.go @@ -1,361 +1,102 @@ package backends import ( - "fmt" - "os" "path/filepath" - "syscall" "testing" - "time" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" ) -func TestFiles(t *testing.T) { - //Initialize Files with mock password and acl files. +func TestFilesBackend(t *testing.T) { + // The bulk of files testing is done in the internal files checker, we'll just check obvious initialization and defaults. + authOpts := make(map[string]string) + logLevel := log.DebugLevel + hasher := hashing.NewHasher(authOpts, "files") - Convey("Given empty opts NewFiles should fail", t, func() { - files, err := NewFiles(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "files")) - So(err, ShouldBeError) + Convey("When files backend is set, missing passwords path should make NewFiles fail when registered to check users", t, func() { + authOpts["backends"] = "files" + authOpts["files_register"] = "user" - files.Halt() + _, err := NewFiles(authOpts, logLevel, hasher) + So(err, ShouldNotBeNil) }) - pwPath, _ := filepath.Abs("../test-files/passwords") - aclPath, _ := filepath.Abs("../test-files/acls") - authOpts["password_path"] = pwPath - authOpts["acl_path"] = aclPath - clientID := "test_client" + Convey("When files backend is set, missing passwords path should not make NewFiles fail when not registered to check users", t, func() { + authOpts["backends"] = "files" + delete(authOpts, "files_register") - Convey("Given valid params NewFiles should return a new files backend instance", t, func() { - files, err := NewFiles(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "files")) + _, err := NewFiles(authOpts, logLevel, hasher) So(err, ShouldBeNil) - - /* - ACL file looks like this: - - topic test/general - topic deny test/general_denied - - user test1 - topic write test/topic/1 - topic read test/topic/2 - - user test2 - topic read test/topic/+ - - user test3 - topic read test/# - topic deny test/denied - - user test with space - topic test/space - topic read test/multiple spaces in/topic - topic read test/lots of spaces in/topic and borders - - user not_present - topic read test/not_present - - pattern read test/%u - pattern read test/%c - */ - - // passwords are the same as users, - // except for user4 that's not present in passwords and should be skipped when reading acls - user1 := "test1" - user2 := "test2" - user3 := "test3" - user4 := "not_present" - elton := "test with space" // You know, because he's a rocket man. Ok, I'll let myself out. - - generalTopic := "test/general" - generalDeniedTopic := "test/general_denied" - - Convey("All users but not present ones should have a record", func() { - _, ok := files.Users[user1] - So(ok, ShouldBeTrue) - - _, ok = files.Users[user2] - So(ok, ShouldBeTrue) - - _, ok = files.Users[user3] - So(ok, ShouldBeTrue) - - _, ok = files.Users[user4] - So(ok, ShouldBeFalse) - - _, ok = files.Users[elton] - So(ok, ShouldBeTrue) - }) - - Convey("All users should be able to read the general topic", func() { - authenticated, err := files.CheckAcl(user1, generalTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - - authenticated, err = files.CheckAcl(user2, generalTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - - authenticated, err = files.CheckAcl(user3, generalTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - - authenticated, err = files.CheckAcl(elton, generalTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - }) - - Convey("No user should be able to read the general denied topic", func() { - authenticated, err := files.CheckAcl(user1, generalDeniedTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeFalse) - - authenticated, err = files.CheckAcl(user2, generalDeniedTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeFalse) - - authenticated, err = files.CheckAcl(user3, generalDeniedTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeFalse) - - authenticated, err = files.CheckAcl(elton, generalDeniedTopic, clientID, 1) - So(err, ShouldBeNil) - So(authenticated, ShouldBeFalse) - }) - - Convey("Given a username and a correct password, it should correctly authenticate it", func() { - authenticated, err := files.GetUser(user1, user1, clientID) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - - authenticated, err = files.GetUser(user2, user2, clientID) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - - authenticated, err = files.GetUser(user3, user3, clientID) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - - authenticated, err = files.GetUser(elton, elton, clientID) - So(err, ShouldBeNil) - So(authenticated, ShouldBeTrue) - }) - - Convey("Given a username and an incorrect password, it should not authenticate it", func() { - authenticated, err := files.GetUser(user1, user2, clientID) - So(err, ShouldBeNil) - So(authenticated, ShouldBeFalse) - }) - - Convey("Given a wrong username, it should not authenticate it and not return error", func() { - authenticated, err := files.GetUser(user4, "whatever_password", "") - So(err, ShouldBeNil) - So(authenticated, ShouldBeFalse) - }) - - //There are no superusers for files - Convey("For any user superuser should return false", func() { - superuser, err := files.GetSuperuser(user1) - So(err, ShouldBeNil) - So(superuser, ShouldBeFalse) - - Convey("Including non-present username", func() { - superuser, err := files.GetSuperuser(user4) - So(err, ShouldBeNil) - So(superuser, ShouldBeFalse) - }) - }) - - testTopic1 := `test/topic/1` - testTopic2 := `test/topic/2` - testTopic3 := `test/other/1` - testTopic4 := `other/1` - readWriteTopic := "readwrite/topic" - spaceTopic := "test/space" - multiSpaceTopic := "test/multiple spaces in/topic" - lotsOfSpacesTopic := "test/lots of spaces in/topic and borders" - deniedTopic := "test/denied" - - Convey("Topics for non existing users should be ignored", func() { - for record := range files.AclRecords { - So(record, ShouldNotEqual, "test/not_present") - } - - for _, user := range files.Users { - for record := range user.AclRecords { - So(record, ShouldNotEqual, "test/not_present") - } - } - }) - - Convey("User 1 should be able to publish and not subscribe to test topic 1, and only subscribe but not publish to topic 2", func() { - tt1, err1 := files.CheckAcl(user1, testTopic1, clientID, 2) - tt2, err2 := files.CheckAcl(user1, testTopic1, clientID, 1) - tt3, err3 := files.CheckAcl(user1, testTopic2, clientID, 2) - tt4, err4 := files.CheckAcl(user1, testTopic2, clientID, 1) - - So(err1, ShouldBeNil) - So(err2, ShouldBeNil) - So(err3, ShouldBeNil) - So(err4, ShouldBeNil) - So(tt1, ShouldBeTrue) - So(tt2, ShouldBeFalse) - So(tt3, ShouldBeFalse) - So(tt4, ShouldBeTrue) - }) - - Convey("User 1 should be able to subscribe or publish to a readwrite topic rule", func() { - tt1, err1 := files.CheckAcl(user1, readWriteTopic, clientID, 2) - tt2, err2 := files.CheckAcl(user1, readWriteTopic, clientID, 1) - So(err1, ShouldBeNil) - So(err2, ShouldBeNil) - So(tt1, ShouldBeTrue) - So(tt2, ShouldBeTrue) - }) - - Convey("User 2 should be able to read any test/topic/X but not any/other", func() { - tt1, err1 := files.CheckAcl(user2, testTopic1, clientID, 1) - tt2, err2 := files.CheckAcl(user2, testTopic2, clientID, 1) - tt3, err3 := files.CheckAcl(user2, testTopic3, clientID, 1) - - So(err1, ShouldBeNil) - So(err2, ShouldBeNil) - So(err3, ShouldBeNil) - So(tt1, ShouldBeTrue) - So(tt2, ShouldBeTrue) - So(tt3, ShouldBeFalse) - }) - - Convey("User 3 should be able to read any test/X but not other/... nor test/denied\n\n", func() { - fmt.Printf("\n\nUser 3 acls: %#v", files.Users[user3].AclRecords) - tt1, err1 := files.CheckAcl(user3, testTopic1, clientID, 1) - tt2, err2 := files.CheckAcl(user3, testTopic2, clientID, 1) - tt3, err3 := files.CheckAcl(user3, testTopic3, clientID, 1) - tt4, err4 := files.CheckAcl(user3, testTopic4, clientID, 1) - tt5, err5 := files.CheckAcl(user3, deniedTopic, clientID, 1) - - So(err1, ShouldBeNil) - So(err2, ShouldBeNil) - So(err3, ShouldBeNil) - So(err4, ShouldBeNil) - So(err5, ShouldBeNil) - So(tt1, ShouldBeTrue) - So(tt2, ShouldBeTrue) - So(tt3, ShouldBeTrue) - So(tt4, ShouldBeFalse) - So(tt5, ShouldBeFalse) - }) - - Convey("User 4 should not be able to read since it's not in the passwords file", func() { - tt1, err1 := files.CheckAcl(user4, testTopic1, clientID, 1) - - So(err1, ShouldBeNil) - So(tt1, ShouldBeFalse) - }) - - Convey("Elton Bowie should be able to read and write to `test/space`, and only read from other topics", func() { - tt1, err1 := files.CheckAcl(elton, spaceTopic, clientID, 2) - tt2, err2 := files.CheckAcl(elton, multiSpaceTopic, clientID, 1) - tt3, err3 := files.CheckAcl(elton, multiSpaceTopic, clientID, 2) - tt4, err4 := files.CheckAcl(elton, lotsOfSpacesTopic, clientID, 1) - tt5, err5 := files.CheckAcl(elton, lotsOfSpacesTopic, clientID, 2) - - So(err1, ShouldBeNil) - So(err2, ShouldBeNil) - So(err3, ShouldBeNil) - So(err4, ShouldBeNil) - So(err5, ShouldBeNil) - So(tt1, ShouldBeTrue) - So(tt2, ShouldBeTrue) - So(tt3, ShouldBeFalse) - So(tt4, ShouldBeTrue) - So(tt5, ShouldBeFalse) - }) - - //Now check against patterns. - Convey("Given a topic that mentions username, acl check should pass", func() { - tt1, err1 := files.CheckAcl(user1, "test/test1", clientID, 1) - So(err1, ShouldBeNil) - So(tt1, ShouldBeTrue) - - tt2, err2 := files.CheckAcl(elton, "test/test with space", clientID, 1) - So(err2, ShouldBeNil) - So(tt2, ShouldBeTrue) - }) - - Convey("Given a topic that mentions clientid, acl check should pass", func() { - tt1, err1 := files.CheckAcl(user1, "test/test_client", clientID, 1) - So(err1, ShouldBeNil) - So(tt1, ShouldBeTrue) - }) - - //Halt files - files.Halt() }) - Convey("On SIGHUP files should be reloaded", t, func() { - pwFile, err := os.Create("../test-files/test-passwords") - So(err, ShouldBeNil) - aclFile, err := os.Create("../test-files/test-acls") + Convey("When passwords path is given, NewFiles should succeed", t, func() { + pwPath, err := filepath.Abs("../test-files/passwords") So(err, ShouldBeNil) - defer os.Remove(pwFile.Name()) - defer os.Remove(aclFile.Name()) + authOpts["backends"] = "files" + authOpts["files_register"] = "user" + authOpts["files_password_path"] = pwPath - hasher := hashing.NewHasher(authOpts, "files") + _, err = NewFiles(authOpts, logLevel, hasher) + So(err, ShouldBeNil) + }) - user1 := "test1" - user2 := "test2" - - pw1, err := hasher.Hash(user1) + Convey("When Files is only registered to check acls and there are no rules for the tested user", t, func() { + aclPath, err := filepath.Abs("../test-files/acls-only") So(err, ShouldBeNil) - pw2, err := hasher.Hash(user2) + authOpts["backends"] = "files" + authOpts["files_register"] = "acl" + authOpts["files_acl_path"] = aclPath + delete(authOpts, "files_password_path") + + f, err := NewFiles(authOpts, logLevel, hasher) So(err, ShouldBeNil) - pwFile.WriteString(fmt.Sprintf("\n%s:%s\n", user1, pw1)) + granted, err := f.CheckAcl("some-user", "any/topic", "client-id", 1) + So(err, ShouldBeNil) + So(granted, ShouldBeTrue) - aclFile.WriteString("\nuser test1") - aclFile.WriteString("\ntopic read test/#") + granted, err = f.CheckAcl("test1", "any/topic", "client-id", 1) + So(err, ShouldBeNil) + So(granted, ShouldBeFalse) + }) - pwFile.Sync() - aclFile.Sync() - - authOpts["password_path"] = pwFile.Name() - authOpts["acl_path"] = aclFile.Name() - - files, err := NewFiles(authOpts, log.DebugLevel, hasher) + Convey("With acls only test case", t, func() { + aclPath, err := filepath.Abs("../test-files/acls-read-only") So(err, ShouldBeNil) - user, ok := files.Users[user1] - So(ok, ShouldBeTrue) + So(err, ShouldBeNil) - record := user.AclRecords[0] - So(record.Acc, ShouldEqual, MOSQ_ACL_READ) - So(record.Topic, ShouldEqual, "test/#") + authOpts["backends"] = "files" + authOpts["files_register"] = "acl" + authOpts["files_acl_path"] = aclPath + delete(authOpts, "files_password_path") - _, ok = files.Users[user2] - So(ok, ShouldBeFalse) + f, err := NewFiles(authOpts, logLevel, hasher) + So(err, ShouldBeNil) - // Now add second user and reload. - pwFile.WriteString(fmt.Sprintf("\n%s:%s\n", user2, pw2)) + granted, err := f.CheckAcl("some-user", "clients/wrong-topic", "client-id", 1) + So(err, ShouldBeNil) + So(granted, ShouldBeFalse) - aclFile.WriteString("\nuser test2") - aclFile.WriteString("\ntopic write test/#") + granted, err = f.CheckAcl("some-user", "clients/wrong-topic", "client-id", 2) + So(err, ShouldBeNil) + So(granted, ShouldBeFalse) - files.signals <- syscall.SIGHUP + granted, err = f.CheckAcl("some-user", "clients/topic", "client-id", 2) + So(err, ShouldBeNil) + So(granted, ShouldBeFalse) - time.Sleep(200 * time.Millisecond) + granted, err = f.CheckAcl("some-user", "clients/topic", "client-id", 1) + So(err, ShouldBeNil) + So(granted, ShouldBeTrue) - user, ok = files.Users[user2] - So(ok, ShouldBeTrue) - - record = user.AclRecords[0] - So(record.Acc, ShouldEqual, MOSQ_ACL_WRITE) - So(record.Topic, ShouldEqual, "test/#") + granted, err = f.CheckAcl("some-user", "clients/client-id", "client-id", 2) + So(err, ShouldBeNil) + So(granted, ShouldBeTrue) }) } diff --git a/backends/http_test.go b/backends/http_test.go index f984df5..b39180e 100644 --- a/backends/http_test.go +++ b/backends/http_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" ) diff --git a/backends/jwt.go b/backends/jwt.go index cad62e8..8ebb2f6 100644 --- a/backends/jwt.go +++ b/backends/jwt.go @@ -39,6 +39,7 @@ const ( remoteMode = "remote" localMode = "local" jsMode = "js" + filesMode = "files" ) func NewJWT(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (*JWT, error) { @@ -83,6 +84,9 @@ func NewJWT(authOpts map[string]string, logLevel log.Level, hasher hashing.HashC case remoteMode: jwt.mode = remoteMode checker, err = NewRemoteJWTChecker(authOpts, options) + case filesMode: + jwt.mode = filesMode + checker, err = NewFilesJWTChecker(authOpts, logLevel, hasher, options) default: err = errors.New("unknown JWT mode") } diff --git a/backends/jwt_files.go b/backends/jwt_files.go new file mode 100644 index 0000000..42fc49f --- /dev/null +++ b/backends/jwt_files.go @@ -0,0 +1,59 @@ +package backends + +import ( + "github.com/iegomez/mosquitto-go-auth/backends/files" + "github.com/iegomez/mosquitto-go-auth/hashing" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +type filesJWTChecker struct { + checker *files.Checker + options tokenOptions +} + +func NewFilesJWTChecker(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer, options tokenOptions) (jwtChecker, error) { + log.SetLevel(logLevel) + + /* We could ask for a file listing available users with no password, but that gives very little value + versus just assuming users in the ACL file are valid ones, while general rules apply to any user. + Thus, padswords file makes no sense for JWT, we only need to check ACLs. + */ + aclPath, ok := authOpts["jwt_acl_path"] + if !ok || aclPath == "" { + return nil, errors.New("missing acl file path") + } + + var checker, err = files.NewChecker(authOpts["backends"], "", aclPath, logLevel, hasher) + if err != nil { + return nil, err + } + + return &filesJWTChecker{ + checker: checker, + options: options, + }, nil +} + +func (o *filesJWTChecker) GetUser(token string) (bool, error) { + return false, nil +} + +func (o *filesJWTChecker) GetSuperuser(token string) (bool, error) { + return false, nil +} + +func (o *filesJWTChecker) CheckAcl(token, topic, clientid string, acc int32) (bool, error) { + username, err := getUsernameForToken(o.options, token, o.options.skipACLExpiration) + + if err != nil { + log.Printf("jwt get user error: %s", err) + return false, err + } + + return o.checker.CheckAcl(username, topic, clientid, acc) +} + +func (o *filesJWTChecker) Halt() { + // NO-OP +} diff --git a/backends/jwt_test.go b/backends/jwt_test.go index 45b5f4b..c7929af 100644 --- a/backends/jwt_test.go +++ b/backends/jwt_test.go @@ -5,12 +5,14 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "path/filepath" "strconv" "strings" "testing" "time" "github.com/dgrijalva/jwt-go" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" @@ -18,7 +20,7 @@ import ( var username = "test" -//Hash generated by the pw utility +// Hash generated by the pw utility var userPassHash = "PBKDF2$sha512$100000$os24lcPr9cJt2QDVWssblQ==$BK1BQ2wbwU1zNxv3Ml3wLuu5//hPop3/LvaPYjjCwdBvnpwusnukJPpcXQzyyjOlZdieXTx6sXAcX4WnZRZZnw==" var jwtSecret = "some_jwt_secret" @@ -55,6 +57,15 @@ var expiredToken = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "username": username, }) +var notPresentJwtToken = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "jwt-test", + "aud": "jwt-test", + "nbf": nowSecondsSinceEpoch, + "exp": expSecondsSinceEpoch, + "sub": "user", + "username": "not_present", +}) + var tkOptions = tokenOptions{ secret: jwtSecret, userField: "Username", @@ -137,7 +148,7 @@ func TestJsJWTChecker(t *testing.T) { aclResponse, err = checker.CheckAcl("incorrect", "test/topic", "id", 1) So(err, ShouldBeNil) - So(userResponse, ShouldBeFalse) + So(aclResponse, ShouldBeFalse) aclResponse, err = checker.CheckAcl("correct", "bad/topic", "id", 1) So(err, ShouldBeNil) @@ -173,6 +184,58 @@ func TestJsJWTChecker(t *testing.T) { }) } +func TestFilesJWTChecker(t *testing.T) { + // The bulk of files testing is done in the internal files checker. + // Neverthelss, we'll check that tokens are effectively parsed and correct usernames get the expected access. + + authOpts := make(map[string]string) + logLevel := log.DebugLevel + hasher := hashing.NewHasher(authOpts, "files") + + Convey("Given empty opts NewFilesJWTChecker should fail", t, func() { + _, err := NewFilesJWTChecker(authOpts, logLevel, hasher, tkOptions) + So(err, ShouldNotBeNil) + }) + + Convey("When files backend is set, missing acl path should make NewFilesJWTChecker fail", t, func() { + authOpts["backends"] = "files" + + _, err := NewFilesJWTChecker(authOpts, logLevel, hasher, tkOptions) + So(err, ShouldNotBeNil) + }) + + Convey("When acl path is given, NewFilesJWTChecker should succeed", t, func() { + pwPath, err := filepath.Abs("../test-files/acls") + So(err, ShouldBeNil) + + authOpts["backends"] = "files" + authOpts["jwt_acl_path"] = pwPath + + filesChecker, err := NewFilesJWTChecker(authOpts, logLevel, hasher, tkOptions) + So(err, ShouldBeNil) + + token, err := notPresentJwtToken.SignedString([]byte(jwtSecret)) + So(err, ShouldBeNil) + + Convey("Access should be granted for ACL mentioned users", func() { + tt, err := filesChecker.CheckAcl(token, "test/not_present", "id", 1) + + So(err, ShouldBeNil) + So(tt, ShouldBeTrue) + }) + + Convey("Access should be granted for general ACL rules on non mentioned users", func() { + tt1, err1 := filesChecker.CheckAcl(token, "test/general", "id", 1) + tt2, err2 := filesChecker.CheckAcl(token, "test/general_denied", "id", 1) + + So(err1, ShouldBeNil) + So(tt1, ShouldBeTrue) + So(err2, ShouldBeNil) + So(tt2, ShouldBeFalse) + }) + }) +} + func TestLocalPostgresJWT(t *testing.T) { Convey("Creating a token should return a nil error", t, func() { diff --git a/backends/mongo.go b/backends/mongo.go index 5d1d2af..6e35b97 100644 --- a/backends/mongo.go +++ b/backends/mongo.go @@ -6,6 +6,8 @@ import ( "strings" "time" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" + "github.com/iegomez/mosquitto-go-auth/backends/topics" "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -199,7 +201,8 @@ func (o Mongo) CheckAcl(username, topic, clientid string, acc int32) (bool, erro } for _, acl := range user.Acls { - if (acl.Acc == acc || acl.Acc == 3) && TopicsMatch(acl.Topic, topic) { + // TODO: needs fixing since it's bypassing MOSQ_ACL_SUBSCRIBE. + if (acl.Acc == acc || acl.Acc == MOSQ_ACL_READWRITE) && topics.Match(acl.Topic, topic) { return true, nil } } @@ -222,7 +225,7 @@ func (o Mongo) CheckAcl(username, topic, clientid string, acc int32) (bool, erro if err == nil { aclTopic := strings.Replace(acl.Topic, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if TopicsMatch(aclTopic, topic) { + if topics.Match(aclTopic, topic) { return true, nil } } else { diff --git a/backends/mongo_test.go b/backends/mongo_test.go index b519a43..eea825e 100644 --- a/backends/mongo_test.go +++ b/backends/mongo_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" diff --git a/backends/mysql.go b/backends/mysql.go index ad2d776..000af51 100644 --- a/backends/mysql.go +++ b/backends/mysql.go @@ -10,6 +10,7 @@ import ( "strings" mq "github.com/go-sql-driver/mysql" + "github.com/iegomez/mosquitto-go-auth/backends/topics" "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -296,7 +297,7 @@ func (o Mysql) CheckAcl(username, topic, clientid string, acc int32) (bool, erro for _, acl := range acls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if TopicsMatch(aclTopic, topic) { + if topics.Match(aclTopic, topic) { return true, nil } } diff --git a/backends/mysql_test.go b/backends/mysql_test.go index eb689a8..fcfae99 100644 --- a/backends/mysql_test.go +++ b/backends/mysql_test.go @@ -3,6 +3,7 @@ package backends import ( "testing" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" diff --git a/backends/postgres.go b/backends/postgres.go index 5ca9d60..8f2fa4a 100644 --- a/backends/postgres.go +++ b/backends/postgres.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/iegomez/mosquitto-go-auth/backends/topics" "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" @@ -242,7 +243,7 @@ func (o Postgres) CheckAcl(username, topic, clientid string, acc int32) (bool, e for _, acl := range acls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if TopicsMatch(aclTopic, topic) { + if topics.Match(aclTopic, topic) { return true, nil } } diff --git a/backends/postgres_test.go b/backends/postgres_test.go index 6382de8..051eac4 100644 --- a/backends/postgres_test.go +++ b/backends/postgres_test.go @@ -3,6 +3,7 @@ package backends import ( "testing" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" diff --git a/backends/redis.go b/backends/redis.go index 0fa5c26..9674537 100644 --- a/backends/redis.go +++ b/backends/redis.go @@ -9,6 +9,8 @@ import ( "time" goredis "github.com/go-redis/redis/v8" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" + "github.com/iegomez/mosquitto-go-auth/backends/topics" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" ) @@ -347,7 +349,7 @@ func (o Redis) checkAcl(username, topic, clientid string, acc int32) (bool, erro //Now loop through acls looking for a match. for _, acl := range acls { - if TopicsMatch(acl, topic) { + if topics.Match(acl, topic) { return true, nil } } @@ -355,7 +357,7 @@ func (o Redis) checkAcl(username, topic, clientid string, acc int32) (bool, erro for _, acl := range commonAcls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if TopicsMatch(aclTopic, topic) { + if topics.Match(aclTopic, topic) { return true, nil } } diff --git a/backends/redis_test.go b/backends/redis_test.go index 27e6e7c..25c970a 100644 --- a/backends/redis_test.go +++ b/backends/redis_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/backends/sqlite.go b/backends/sqlite.go index 4a4a95b..926ddd1 100644 --- a/backends/sqlite.go +++ b/backends/sqlite.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/iegomez/mosquitto-go-auth/backends/topics" "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" @@ -175,7 +176,7 @@ func (o Sqlite) CheckAcl(username, topic, clientid string, acc int32) (bool, err for _, acl := range acls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if TopicsMatch(aclTopic, topic) { + if topics.Match(aclTopic, topic) { return true, nil } } diff --git a/backends/sqlite_test.go b/backends/sqlite_test.go index 4777ada..32c27d8 100644 --- a/backends/sqlite_test.go +++ b/backends/sqlite_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + . "github.com/iegomez/mosquitto-go-auth/backends/constants" "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" diff --git a/backends/topic.go b/backends/topics/topics.go similarity index 57% rename from backends/topic.go rename to backends/topics/topics.go index c544d81..187ce74 100644 --- a/backends/topic.go +++ b/backends/topics/topics.go @@ -1,11 +1,15 @@ -package backends +package topics import "strings" -func TopicsMatch(savedTopic, givenTopic string) bool { +// Match tells if givenTopic matches savedTopic's pattern. +func Match(savedTopic, givenTopic string) bool { return givenTopic == savedTopic || match(strings.Split(savedTopic, "/"), strings.Split(givenTopic, "/")) } +// TODO: I've always trusted this function does the right thing, +// and it's kind of been proven by use and indirect testing of backends, +// but it should really have tests of its own. func match(route []string, topic []string) bool { switch { case len(route) == 0: diff --git a/docker/conf/conf.d/go-auth.conf b/docker/conf/conf.d/go-auth.conf index 919a1ad..6cc0f02 100644 --- a/docker/conf/conf.d/go-auth.conf +++ b/docker/conf/conf.d/go-auth.conf @@ -4,8 +4,8 @@ auth_opt_log_level debug auth_opt_backends files auth_opt_check_prefix false -auth_opt_password_path /etc/mosquitto/auth/passwords -auth_opt_acl_path /etc/mosquitto/auth/acls +auth_opt_files_password_path /etc/mosquitto/auth/passwords +auth_opt_files_acl_path /etc/mosquitto/auth/acls auth_opt_cache_host redis auth_opt_cache true diff --git a/test-files/acls-only b/test-files/acls-only new file mode 100644 index 0000000..e7b922e --- /dev/null +++ b/test-files/acls-only @@ -0,0 +1,2 @@ +user some-user +topic # diff --git a/test-files/acls-read-only b/test-files/acls-read-only new file mode 100644 index 0000000..05d9816 --- /dev/null +++ b/test-files/acls-read-only @@ -0,0 +1,4 @@ +user some-user +topic read clients/topic + +pattern write clients/%c