代码整理
This commit is contained in:
41
hooks/auth/allow_all.go
Normal file
41
hooks/auth/allow_all.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2022 mochi-mqtt, mochi-co
|
||||
// SPDX-FileContributor: mochi-co
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"testmqtt/mqtt"
|
||||
"testmqtt/packets"
|
||||
)
|
||||
|
||||
// AllowHook is an authentication hook which allows connection access
|
||||
// for all users and read and write access to all topics.
|
||||
type AllowHook struct {
|
||||
mqtt.HookBase
|
||||
}
|
||||
|
||||
// ID returns the ID of the hook.
|
||||
func (h *AllowHook) ID() string {
|
||||
return "allow-all-auth"
|
||||
}
|
||||
|
||||
// Provides indicates which hook methods this hook provides.
|
||||
func (h *AllowHook) Provides(b byte) bool {
|
||||
return bytes.Contains([]byte{
|
||||
mqtt.OnConnectAuthenticate,
|
||||
mqtt.OnACLCheck,
|
||||
}, []byte{b})
|
||||
}
|
||||
|
||||
// OnConnectAuthenticate returns true/allowed for all requests.
|
||||
func (h *AllowHook) OnConnectAuthenticate(cl *mqtt.Client, pk packets.Packet) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// OnACLCheck returns true/allowed for all checks.
|
||||
func (h *AllowHook) OnACLCheck(cl *mqtt.Client, topic string, write bool) bool {
|
||||
return true
|
||||
}
|
||||
35
hooks/auth/allow_all_test.go
Normal file
35
hooks/auth/allow_all_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2022 mochi-mqtt, mochi-co
|
||||
// SPDX-FileContributor: mochi-co
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"testmqtt/mqtt"
|
||||
"testmqtt/packets"
|
||||
)
|
||||
|
||||
func TestAllowAllID(t *testing.T) {
|
||||
h := new(AllowHook)
|
||||
require.Equal(t, "allow-all-auth", h.ID())
|
||||
}
|
||||
|
||||
func TestAllowAllProvides(t *testing.T) {
|
||||
h := new(AllowHook)
|
||||
require.True(t, h.Provides(mqtt.OnACLCheck))
|
||||
require.True(t, h.Provides(mqtt.OnConnectAuthenticate))
|
||||
require.False(t, h.Provides(mqtt.OnPublished))
|
||||
}
|
||||
|
||||
func TestAllowAllOnConnectAuthenticate(t *testing.T) {
|
||||
h := new(AllowHook)
|
||||
require.True(t, h.OnConnectAuthenticate(new(mqtt.Client), packets.Packet{}))
|
||||
}
|
||||
|
||||
func TestAllowAllOnACLCheck(t *testing.T) {
|
||||
h := new(AllowHook)
|
||||
require.True(t, h.OnACLCheck(new(mqtt.Client), "any", true))
|
||||
}
|
||||
103
hooks/auth/auth.go
Normal file
103
hooks/auth/auth.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2022 mochi-mqtt, mochi-co
|
||||
// SPDX-FileContributor: mochi-co
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"testmqtt/mqtt"
|
||||
"testmqtt/packets"
|
||||
)
|
||||
|
||||
// Options contains the configuration/rules data for the auth ledger.
|
||||
type Options struct {
|
||||
Data []byte
|
||||
Ledger *Ledger
|
||||
}
|
||||
|
||||
// Hook is an authentication hook which implements an auth ledger.
|
||||
type Hook struct {
|
||||
mqtt.HookBase
|
||||
config *Options
|
||||
ledger *Ledger
|
||||
}
|
||||
|
||||
// ID returns the ID of the hook.
|
||||
func (h *Hook) ID() string {
|
||||
return "auth-ledger"
|
||||
}
|
||||
|
||||
// Provides indicates which hook methods this hook provides.
|
||||
func (h *Hook) Provides(b byte) bool {
|
||||
return bytes.Contains([]byte{
|
||||
mqtt.OnConnectAuthenticate,
|
||||
mqtt.OnACLCheck,
|
||||
}, []byte{b})
|
||||
}
|
||||
|
||||
// Init configures the hook with the auth ledger to be used for checking.
|
||||
func (h *Hook) Init(config any) error {
|
||||
if _, ok := config.(*Options); !ok && config != nil {
|
||||
return mqtt.ErrInvalidConfigType
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
config = new(Options)
|
||||
}
|
||||
|
||||
h.config = config.(*Options)
|
||||
|
||||
var err error
|
||||
if h.config.Ledger != nil {
|
||||
h.ledger = h.config.Ledger
|
||||
} else if len(h.config.Data) > 0 {
|
||||
h.ledger = new(Ledger)
|
||||
err = h.ledger.Unmarshal(h.config.Data)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.ledger == nil {
|
||||
h.ledger = &Ledger{
|
||||
Auth: AuthRules{},
|
||||
ACL: ACLRules{},
|
||||
}
|
||||
}
|
||||
|
||||
h.Log.Info("loaded auth rules",
|
||||
"authentication", len(h.ledger.Auth),
|
||||
"acl", len(h.ledger.ACL))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnConnectAuthenticate returns true if the connecting client has rules which provide access
|
||||
// in the auth ledger.
|
||||
func (h *Hook) OnConnectAuthenticate(cl *mqtt.Client, pk packets.Packet) bool {
|
||||
if _, ok := h.ledger.AuthOk(cl, pk); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
h.Log.Info("client failed authentication check",
|
||||
"username", string(pk.Connect.Username),
|
||||
"remote", cl.Net.Remote)
|
||||
return false
|
||||
}
|
||||
|
||||
// OnACLCheck returns true if the connecting client has matching read or write access to subscribe
|
||||
// or publish to a given topic.
|
||||
func (h *Hook) OnACLCheck(cl *mqtt.Client, topic string, write bool) bool {
|
||||
if _, ok := h.ledger.ACLOk(cl, topic, write); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
h.Log.Debug("client failed allowed ACL check",
|
||||
"client", cl.ID,
|
||||
"username", string(cl.Properties.Username),
|
||||
"topic", topic)
|
||||
|
||||
return false
|
||||
}
|
||||
213
hooks/auth/auth_test.go
Normal file
213
hooks/auth/auth_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2022 mochi-mqtt, mochi-co
|
||||
// SPDX-FileContributor: mochi-co
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"testmqtt/mqtt"
|
||||
"testmqtt/packets"
|
||||
)
|
||||
|
||||
var logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
// func teardown(t *testing.T, path string, h *Hook) {
|
||||
// h.Stop()
|
||||
// }
|
||||
|
||||
func TestBasicID(t *testing.T) {
|
||||
h := new(Hook)
|
||||
require.Equal(t, "auth-ledger", h.ID())
|
||||
}
|
||||
|
||||
func TestBasicProvides(t *testing.T) {
|
||||
h := new(Hook)
|
||||
require.True(t, h.Provides(mqtt.OnACLCheck))
|
||||
require.True(t, h.Provides(mqtt.OnConnectAuthenticate))
|
||||
require.False(t, h.Provides(mqtt.OnPublish))
|
||||
}
|
||||
|
||||
func TestBasicInitBadConfig(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
err := h.Init(map[string]any{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBasicInitDefaultConfig(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
err := h.Init(nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestBasicInitWithLedgerPointer(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
ln := &Ledger{
|
||||
Auth: []AuthRule{
|
||||
{
|
||||
Remote: "127.0.0.1",
|
||||
Allow: true,
|
||||
},
|
||||
},
|
||||
ACL: []ACLRule{
|
||||
{
|
||||
Remote: "127.0.0.1",
|
||||
Filters: Filters{
|
||||
"#": ReadWrite,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := h.Init(&Options{
|
||||
Ledger: ln,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Same(t, ln, h.ledger)
|
||||
}
|
||||
|
||||
func TestBasicInitWithLedgerJSON(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
require.Nil(t, h.ledger)
|
||||
err := h.Init(&Options{
|
||||
Data: ledgerJSON,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ledgerStruct.Auth[0].Username, h.ledger.Auth[0].Username)
|
||||
require.Equal(t, ledgerStruct.ACL[0].Client, h.ledger.ACL[0].Client)
|
||||
}
|
||||
|
||||
func TestBasicInitWithLedgerYAML(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
require.Nil(t, h.ledger)
|
||||
err := h.Init(&Options{
|
||||
Data: ledgerYAML,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ledgerStruct.Auth[0].Username, h.ledger.Auth[0].Username)
|
||||
require.Equal(t, ledgerStruct.ACL[0].Client, h.ledger.ACL[0].Client)
|
||||
}
|
||||
|
||||
func TestBasicInitWithLedgerBadDAta(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
require.Nil(t, h.ledger)
|
||||
err := h.Init(&Options{
|
||||
Data: []byte("fdsfdsafasd"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOnConnectAuthenticate(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
ln := new(Ledger)
|
||||
ln.Auth = checkLedger.Auth
|
||||
ln.ACL = checkLedger.ACL
|
||||
err := h.Init(
|
||||
&Options{
|
||||
Ledger: ln,
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, h.OnConnectAuthenticate(
|
||||
&mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
|
||||
))
|
||||
|
||||
require.False(t, h.OnConnectAuthenticate(
|
||||
&mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
|
||||
))
|
||||
|
||||
require.False(t, h.OnConnectAuthenticate(
|
||||
&mqtt.Client{},
|
||||
packets.Packet{},
|
||||
))
|
||||
}
|
||||
|
||||
func TestOnACL(t *testing.T) {
|
||||
h := new(Hook)
|
||||
h.SetOpts(logger, nil)
|
||||
|
||||
ln := new(Ledger)
|
||||
ln.Auth = checkLedger.Auth
|
||||
ln.ACL = checkLedger.ACL
|
||||
err := h.Init(
|
||||
&Options{
|
||||
Ledger: ln,
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, h.OnACLCheck(
|
||||
&mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
"mochi/info",
|
||||
true,
|
||||
))
|
||||
|
||||
require.False(t, h.OnACLCheck(
|
||||
&mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
"d/j/f",
|
||||
true,
|
||||
))
|
||||
|
||||
require.True(t, h.OnACLCheck(
|
||||
&mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
"readonly",
|
||||
false,
|
||||
))
|
||||
|
||||
require.False(t, h.OnACLCheck(
|
||||
&mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
"readonly",
|
||||
true,
|
||||
))
|
||||
}
|
||||
246
hooks/auth/ledger.go
Normal file
246
hooks/auth/ledger.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2022 mochi-mqtt, mochi-co
|
||||
// SPDX-FileContributor: mochi-co
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"testmqtt/mqtt"
|
||||
"testmqtt/packets"
|
||||
)
|
||||
|
||||
const (
|
||||
Deny Access = iota // user cannot access the topic
|
||||
ReadOnly // user can only subscribe to the topic
|
||||
WriteOnly // user can only publish to the topic
|
||||
ReadWrite // user can both publish and subscribe to the topic
|
||||
)
|
||||
|
||||
// Access determines the read/write privileges for an ACL rule.
|
||||
type Access byte
|
||||
|
||||
// Users contains a map of access rules for specific users, keyed on username.
|
||||
type Users map[string]UserRule
|
||||
|
||||
// UserRule defines a set of access rules for a specific user.
|
||||
type UserRule struct {
|
||||
Username RString `json:"username,omitempty" yaml:"username,omitempty"` // the username of a user
|
||||
Password RString `json:"password,omitempty" yaml:"password,omitempty"` // the password of a user
|
||||
ACL Filters `json:"acl,omitempty" yaml:"acl,omitempty"` // filters to match, if desired
|
||||
Disallow bool `json:"disallow,omitempty" yaml:"disallow,omitempty"` // allow or disallow the user
|
||||
}
|
||||
|
||||
// AuthRules defines generic access rules applicable to all users.
|
||||
type AuthRules []AuthRule
|
||||
|
||||
type AuthRule struct {
|
||||
Client RString `json:"client,omitempty" yaml:"client,omitempty"` // the id of a connecting client
|
||||
Username RString `json:"username,omitempty" yaml:"username,omitempty"` // the username of a user
|
||||
Remote RString `json:"remote,omitempty" yaml:"remote,omitempty"` // remote address or
|
||||
Password RString `json:"password,omitempty" yaml:"password,omitempty"` // the password of a user
|
||||
Allow bool `json:"allow,omitempty" yaml:"allow,omitempty"` // allow or disallow the users
|
||||
}
|
||||
|
||||
// ACLRules defines generic topic or filter access rules applicable to all users.
|
||||
type ACLRules []ACLRule
|
||||
|
||||
// ACLRule defines access rules for a specific topic or filter.
|
||||
type ACLRule struct {
|
||||
Client RString `json:"client,omitempty" yaml:"client,omitempty"` // the id of a connecting client
|
||||
Username RString `json:"username,omitempty" yaml:"username,omitempty"` // the username of a user
|
||||
Remote RString `json:"remote,omitempty" yaml:"remote,omitempty"` // remote address or
|
||||
Filters Filters `json:"filters,omitempty" yaml:"filters,omitempty"` // filters to match
|
||||
}
|
||||
|
||||
// Filters is a map of Access rules keyed on filter.
|
||||
type Filters map[RString]Access
|
||||
|
||||
// RString is a rule value string.
|
||||
type RString string
|
||||
|
||||
// Matches returns true if the rule matches a given string.
|
||||
func (r RString) Matches(a string) bool {
|
||||
rr := string(r)
|
||||
if r == "" || r == "*" || a == rr {
|
||||
return true
|
||||
}
|
||||
|
||||
i := strings.Index(rr, "*")
|
||||
if i > 0 && len(a) > i && strings.Compare(rr[:i], a[:i]) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterMatches returns true if a filter matches a topic rule.
|
||||
func (r RString) FilterMatches(a string) bool {
|
||||
_, ok := MatchTopic(string(r), a)
|
||||
return ok
|
||||
}
|
||||
|
||||
// MatchTopic checks if a given topic matches a filter, accounting for filter
|
||||
// wildcards. Eg. filter /a/b/+/c == topic a/b/d/c.
|
||||
func MatchTopic(filter string, topic string) (elements []string, matched bool) {
|
||||
filterParts := strings.Split(filter, "/")
|
||||
topicParts := strings.Split(topic, "/")
|
||||
|
||||
elements = make([]string, 0)
|
||||
for i := 0; i < len(filterParts); i++ {
|
||||
if i >= len(topicParts) {
|
||||
matched = false
|
||||
return
|
||||
}
|
||||
|
||||
if filterParts[i] == "+" {
|
||||
elements = append(elements, topicParts[i])
|
||||
continue
|
||||
}
|
||||
|
||||
if filterParts[i] == "#" {
|
||||
matched = true
|
||||
elements = append(elements, strings.Join(topicParts[i:], "/"))
|
||||
return
|
||||
}
|
||||
|
||||
if filterParts[i] != topicParts[i] {
|
||||
matched = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return elements, true
|
||||
}
|
||||
|
||||
// Ledger is an auth ledger containing access rules for users and topics.
|
||||
type Ledger struct {
|
||||
sync.Mutex `json:"-" yaml:"-"`
|
||||
Users Users `json:"users" yaml:"users"`
|
||||
Auth AuthRules `json:"auth" yaml:"auth"`
|
||||
ACL ACLRules `json:"acl" yaml:"acl"`
|
||||
}
|
||||
|
||||
// Update updates the internal values of the ledger.
|
||||
func (l *Ledger) Update(ln *Ledger) {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
l.Auth = ln.Auth
|
||||
l.ACL = ln.ACL
|
||||
}
|
||||
|
||||
// AuthOk returns true if the rules indicate the user is allowed to authenticate.
|
||||
func (l *Ledger) AuthOk(cl *mqtt.Client, pk packets.Packet) (n int, ok bool) {
|
||||
// If the users map is set, always check for a predefined user first instead
|
||||
// of iterating through global rules.
|
||||
if l.Users != nil {
|
||||
if u, ok := l.Users[string(cl.Properties.Username)]; ok &&
|
||||
u.Password != "" &&
|
||||
u.Password == RString(pk.Connect.Password) {
|
||||
return 0, !u.Disallow
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no users map, or no user was found, attempt to find a matching
|
||||
// rule (which may also contain a user).
|
||||
for n, rule := range l.Auth {
|
||||
if rule.Client.Matches(cl.ID) &&
|
||||
rule.Username.Matches(string(cl.Properties.Username)) &&
|
||||
rule.Password.Matches(string(pk.Connect.Password)) &&
|
||||
rule.Remote.Matches(cl.Net.Remote) {
|
||||
return n, rule.Allow
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ACLOk returns true if the rules indicate the user is allowed to read or write to
|
||||
// a specific filter or topic respectively, based on the `write` bool.
|
||||
func (l *Ledger) ACLOk(cl *mqtt.Client, topic string, write bool) (n int, ok bool) {
|
||||
// If the users map is set, always check for a predefined user first instead
|
||||
// of iterating through global rules.
|
||||
if l.Users != nil {
|
||||
if u, ok := l.Users[string(cl.Properties.Username)]; ok && len(u.ACL) > 0 {
|
||||
for filter, access := range u.ACL {
|
||||
if filter.FilterMatches(topic) {
|
||||
if !write && (access == ReadOnly || access == ReadWrite) {
|
||||
return n, true
|
||||
} else if write && (access == WriteOnly || access == ReadWrite) {
|
||||
return n, true
|
||||
} else {
|
||||
return n, false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for n, rule := range l.ACL {
|
||||
if rule.Client.Matches(cl.ID) &&
|
||||
rule.Username.Matches(string(cl.Properties.Username)) &&
|
||||
rule.Remote.Matches(cl.Net.Remote) {
|
||||
if len(rule.Filters) == 0 {
|
||||
return n, true
|
||||
}
|
||||
|
||||
if write {
|
||||
for filter, access := range rule.Filters {
|
||||
if access == WriteOnly || access == ReadWrite {
|
||||
if filter.FilterMatches(topic) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !write {
|
||||
for filter, access := range rule.Filters {
|
||||
if access == ReadOnly || access == ReadWrite {
|
||||
if filter.FilterMatches(topic) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for filter := range rule.Filters {
|
||||
if filter.FilterMatches(topic) {
|
||||
return n, false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// ToJSON encodes the values into a JSON string.
|
||||
func (l *Ledger) ToJSON() (data []byte, err error) {
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// ToYAML encodes the values into a YAML string.
|
||||
func (l *Ledger) ToYAML() (data []byte, err error) {
|
||||
return yaml.Marshal(l)
|
||||
}
|
||||
|
||||
// Unmarshal decodes a JSON or YAML string (such as a rule config from a file) into a struct.
|
||||
func (l *Ledger) Unmarshal(data []byte) error {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if data[0] == '{' {
|
||||
return json.Unmarshal(data, l)
|
||||
}
|
||||
|
||||
return yaml.Unmarshal(data, &l)
|
||||
}
|
||||
610
hooks/auth/ledger_test.go
Normal file
610
hooks/auth/ledger_test.go
Normal file
@@ -0,0 +1,610 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2022 mochi-mqtt, mochi-co
|
||||
// SPDX-FileContributor: mochi-co
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"testmqtt/mqtt"
|
||||
"testmqtt/packets"
|
||||
)
|
||||
|
||||
var (
|
||||
checkLedger = Ledger{
|
||||
Users: Users{ // users are allowed by default
|
||||
"mochi-co": {
|
||||
Password: "melon",
|
||||
ACL: Filters{
|
||||
"d/+/f": Deny,
|
||||
"mochi-co/#": ReadWrite,
|
||||
"readonly": ReadOnly,
|
||||
},
|
||||
},
|
||||
"suspended-username": {
|
||||
Password: "any",
|
||||
Disallow: true,
|
||||
},
|
||||
"mochi": { // ACL only, will defer to AuthRules for authentication
|
||||
ACL: Filters{
|
||||
"special/mochi": ReadOnly,
|
||||
"secret/mochi": Deny,
|
||||
"ignored": ReadWrite,
|
||||
},
|
||||
},
|
||||
},
|
||||
Auth: AuthRules{
|
||||
{Username: "banned-user"}, // never allow specific username
|
||||
{Remote: "127.0.0.1", Allow: true}, // always allow localhost
|
||||
{Remote: "123.123.123.123"}, // disallow any from specific address
|
||||
{Username: "not-mochi", Remote: "111.144.155.166"}, // disallow specific username and address
|
||||
{Remote: "111.*", Allow: true}, // allow any in wildcard (that isn't the above username)
|
||||
{Username: "mochi", Password: "melon", Allow: true}, // allow matching user/pass
|
||||
{Username: "mochi-co", Password: "melon", Allow: false}, // allow matching user/pass (should never trigger due to Users map)
|
||||
},
|
||||
ACL: ACLRules{
|
||||
{
|
||||
Username: "mochi", // allow matching user/pass
|
||||
Filters: Filters{
|
||||
"a/b/c": Deny,
|
||||
"d/+/f": Deny,
|
||||
"mochi/#": ReadWrite,
|
||||
"updates/#": WriteOnly,
|
||||
"readonly": ReadOnly,
|
||||
"ignored": Deny,
|
||||
},
|
||||
},
|
||||
{Remote: "localhost", Filters: Filters{"$SYS/#": ReadOnly}}, // allow $SYS access to localhost
|
||||
{Username: "admin", Filters: Filters{"$SYS/#": ReadOnly}}, // allow $SYS access to admin
|
||||
{Remote: "001.002.003.004"}, // Allow all with no filter
|
||||
{Filters: Filters{"$SYS/#": Deny}}, // Deny $SYS access to all others
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestRStringMatches(t *testing.T) {
|
||||
require.True(t, RString("*").Matches("any"))
|
||||
require.True(t, RString("*").Matches(""))
|
||||
require.True(t, RString("").Matches("any"))
|
||||
require.True(t, RString("").Matches(""))
|
||||
require.False(t, RString("no").Matches("any"))
|
||||
require.False(t, RString("no").Matches(""))
|
||||
}
|
||||
|
||||
func TestCanAuthenticate(t *testing.T) {
|
||||
tt := []struct {
|
||||
desc string
|
||||
client *mqtt.Client
|
||||
pk packets.Packet
|
||||
n int
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
desc: "allow all local 127.0.0.1",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "127.0.0.1",
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{}},
|
||||
ok: true,
|
||||
n: 1,
|
||||
},
|
||||
{
|
||||
desc: "allow username/password",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
|
||||
ok: true,
|
||||
n: 5,
|
||||
},
|
||||
{
|
||||
desc: "deny username/password",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
|
||||
ok: false,
|
||||
n: 0,
|
||||
},
|
||||
{
|
||||
desc: "allow all local 127.0.0.1",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "127.0.0.1",
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
|
||||
ok: true,
|
||||
n: 1,
|
||||
},
|
||||
{
|
||||
desc: "allow username/password",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
|
||||
ok: true,
|
||||
n: 5,
|
||||
},
|
||||
{
|
||||
desc: "deny username/password",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
|
||||
ok: false,
|
||||
n: 0,
|
||||
},
|
||||
{
|
||||
desc: "deny client from address",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("not-mochi"),
|
||||
},
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "111.144.155.166",
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{},
|
||||
ok: false,
|
||||
n: 3,
|
||||
},
|
||||
{
|
||||
desc: "allow remote wildcard",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "111.0.0.1",
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{},
|
||||
ok: true,
|
||||
n: 4,
|
||||
},
|
||||
{
|
||||
desc: "never allow username",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("banned-user"),
|
||||
},
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "127.0.0.1",
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{},
|
||||
ok: false,
|
||||
n: 0,
|
||||
},
|
||||
{
|
||||
desc: "matching user in users",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi-co"),
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
|
||||
ok: true,
|
||||
n: 0,
|
||||
},
|
||||
{
|
||||
desc: "never user in users",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("suspended-user"),
|
||||
},
|
||||
},
|
||||
pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("any")}},
|
||||
ok: false,
|
||||
n: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range tt {
|
||||
t.Run(d.desc, func(t *testing.T) {
|
||||
n, ok := checkLedger.AuthOk(d.client, d.pk)
|
||||
require.Equal(t, d.n, n)
|
||||
require.Equal(t, d.ok, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanACL(t *testing.T) {
|
||||
tt := []struct {
|
||||
client *mqtt.Client
|
||||
desc string
|
||||
topic string
|
||||
n int
|
||||
write bool
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
desc: "allow normal write on any other filter",
|
||||
client: &mqtt.Client{},
|
||||
topic: "default/acl/write/access",
|
||||
write: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "allow normal read on any other filter",
|
||||
client: &mqtt.Client{},
|
||||
topic: "default/acl/read/access",
|
||||
write: false,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "deny user on literal filter",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "a/b/c",
|
||||
},
|
||||
{
|
||||
desc: "deny user on partial filter",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "d/j/f",
|
||||
},
|
||||
{
|
||||
desc: "allow read/write to user path",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "mochi/read/write",
|
||||
write: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "deny read on write-only path",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "updates/no/reading",
|
||||
write: false,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
desc: "deny read on write-only path ext",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "updates/mochi",
|
||||
write: false,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
desc: "allow read on not-acl path (no #)",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "updates",
|
||||
write: false,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "allow write on write-only path",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "updates/mochi",
|
||||
write: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "deny write on read-only path",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "readonly",
|
||||
write: true,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
desc: "allow read on read-only path",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "readonly",
|
||||
write: false,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "allow $sys access to localhost",
|
||||
client: &mqtt.Client{
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "localhost",
|
||||
},
|
||||
},
|
||||
topic: "$SYS/test",
|
||||
write: false,
|
||||
ok: true,
|
||||
n: 1,
|
||||
},
|
||||
{
|
||||
desc: "allow $sys access to admin",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("admin"),
|
||||
},
|
||||
},
|
||||
topic: "$SYS/test",
|
||||
write: false,
|
||||
ok: true,
|
||||
n: 2,
|
||||
},
|
||||
{
|
||||
desc: "deny $sys access to all others",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "$SYS/test",
|
||||
write: false,
|
||||
ok: false,
|
||||
n: 4,
|
||||
},
|
||||
{
|
||||
desc: "allow all with no filter",
|
||||
client: &mqtt.Client{
|
||||
Net: mqtt.ClientConnection{
|
||||
Remote: "001.002.003.004",
|
||||
},
|
||||
},
|
||||
topic: "any/path",
|
||||
write: true,
|
||||
ok: true,
|
||||
n: 3,
|
||||
},
|
||||
{
|
||||
desc: "use users embedded acl deny",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "secret/mochi",
|
||||
write: true,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
desc: "use users embedded acl any",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "any/mochi",
|
||||
write: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "use users embedded acl write on read-only",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "special/mochi",
|
||||
write: true,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
desc: "use users embedded acl read on read-only",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "special/mochi",
|
||||
write: false,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
desc: "preference users embedded acl",
|
||||
client: &mqtt.Client{
|
||||
Properties: mqtt.ClientProperties{
|
||||
Username: []byte("mochi"),
|
||||
},
|
||||
},
|
||||
topic: "ignored",
|
||||
write: true,
|
||||
ok: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range tt {
|
||||
t.Run(d.desc, func(t *testing.T) {
|
||||
n, ok := checkLedger.ACLOk(d.client, d.topic, d.write)
|
||||
require.Equal(t, d.n, n)
|
||||
require.Equal(t, d.ok, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTopic(t *testing.T) {
|
||||
el, matched := MatchTopic("a/+/c/+", "a/b/c/d")
|
||||
require.True(t, matched)
|
||||
require.Equal(t, []string{"b", "d"}, el)
|
||||
|
||||
el, matched = MatchTopic("a/+/+/+", "a/b/c/d")
|
||||
require.True(t, matched)
|
||||
require.Equal(t, []string{"b", "c", "d"}, el)
|
||||
|
||||
el, matched = MatchTopic("stuff/#", "stuff/things/yeah")
|
||||
require.True(t, matched)
|
||||
require.Equal(t, []string{"things/yeah"}, el)
|
||||
|
||||
el, matched = MatchTopic("a/+/#/+", "a/b/c/d/as/dds")
|
||||
require.True(t, matched)
|
||||
require.Equal(t, []string{"b", "c/d/as/dds"}, el)
|
||||
|
||||
el, matched = MatchTopic("test", "test")
|
||||
require.True(t, matched)
|
||||
require.Equal(t, make([]string, 0), el)
|
||||
|
||||
el, matched = MatchTopic("things/stuff//", "things/stuff/")
|
||||
require.False(t, matched)
|
||||
require.Equal(t, make([]string, 0), el)
|
||||
|
||||
el, matched = MatchTopic("t", "t2")
|
||||
require.False(t, matched)
|
||||
require.Equal(t, make([]string, 0), el)
|
||||
|
||||
el, matched = MatchTopic(" ", " ")
|
||||
require.False(t, matched)
|
||||
require.Equal(t, make([]string, 0), el)
|
||||
}
|
||||
|
||||
var (
|
||||
ledgerStruct = Ledger{
|
||||
Users: Users{
|
||||
"mochi": {
|
||||
Password: "peach",
|
||||
ACL: Filters{
|
||||
"readonly": ReadOnly,
|
||||
"deny": Deny,
|
||||
},
|
||||
},
|
||||
},
|
||||
Auth: AuthRules{
|
||||
{
|
||||
Client: "*",
|
||||
Username: "mochi-co",
|
||||
Password: "melon",
|
||||
Remote: "192.168.1.*",
|
||||
Allow: true,
|
||||
},
|
||||
},
|
||||
ACL: ACLRules{
|
||||
{
|
||||
Client: "*",
|
||||
Username: "mochi-co",
|
||||
Remote: "127.*",
|
||||
Filters: Filters{
|
||||
"readonly": ReadOnly,
|
||||
"writeonly": WriteOnly,
|
||||
"readwrite": ReadWrite,
|
||||
"deny": Deny,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ledgerJSON = []byte(`{"users":{"mochi":{"password":"peach","acl":{"deny":0,"readonly":1}}},"auth":[{"client":"*","username":"mochi-co","remote":"192.168.1.*","password":"melon","allow":true}],"acl":[{"client":"*","username":"mochi-co","remote":"127.*","filters":{"deny":0,"readonly":1,"readwrite":3,"writeonly":2}}]}`)
|
||||
ledgerYAML = []byte(`users:
|
||||
mochi:
|
||||
password: peach
|
||||
acl:
|
||||
deny: 0
|
||||
readonly: 1
|
||||
auth:
|
||||
- client: '*'
|
||||
username: mochi-co
|
||||
remote: 192.168.1.*
|
||||
password: melon
|
||||
allow: true
|
||||
acl:
|
||||
- client: '*'
|
||||
username: mochi-co
|
||||
remote: 127.*
|
||||
filters:
|
||||
deny: 0
|
||||
readonly: 1
|
||||
readwrite: 3
|
||||
writeonly: 2
|
||||
`)
|
||||
)
|
||||
|
||||
func TestLedgerUpdate(t *testing.T) {
|
||||
old := &Ledger{
|
||||
Auth: AuthRules{
|
||||
{Remote: "127.0.0.1", Allow: true},
|
||||
},
|
||||
}
|
||||
|
||||
n := &Ledger{
|
||||
Auth: AuthRules{
|
||||
{Remote: "127.0.0.1", Allow: true},
|
||||
{Remote: "192.168.*", Allow: true},
|
||||
},
|
||||
}
|
||||
|
||||
old.Update(n)
|
||||
require.Len(t, old.Auth, 2)
|
||||
require.Equal(t, RString("192.168.*"), old.Auth[1].Remote)
|
||||
require.NotSame(t, n, old)
|
||||
}
|
||||
|
||||
func TestLedgerToJSON(t *testing.T) {
|
||||
data, err := ledgerStruct.ToJSON()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ledgerJSON, data)
|
||||
}
|
||||
|
||||
func TestLedgerToYAML(t *testing.T) {
|
||||
data, err := ledgerStruct.ToYAML()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ledgerYAML, data)
|
||||
}
|
||||
|
||||
func TestLedgerUnmarshalFromYAML(t *testing.T) {
|
||||
l := new(Ledger)
|
||||
err := l.Unmarshal(ledgerYAML)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &ledgerStruct, l)
|
||||
require.NotSame(t, l, &ledgerStruct)
|
||||
}
|
||||
|
||||
func TestLedgerUnmarshalFromJSON(t *testing.T) {
|
||||
l := new(Ledger)
|
||||
err := l.Unmarshal(ledgerJSON)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &ledgerStruct, l)
|
||||
require.NotSame(t, l, &ledgerStruct)
|
||||
}
|
||||
|
||||
func TestLedgerUnmarshalNil(t *testing.T) {
|
||||
l := new(Ledger)
|
||||
err := l.Unmarshal([]byte{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, new(Ledger), l)
|
||||
}
|
||||
Reference in New Issue
Block a user