Add javascript backend.

This commit is contained in:
Ignacio Gómez 2021-02-10 21:38:24 -03:00
parent fc44c811d2
commit 7a844596a0
No known key found for this signature in database
GPG Key ID: 15A77C6BEC604B06
14 changed files with 349 additions and 17 deletions

View File

@ -8,11 +8,10 @@ I don't use Mosquitto or any other MQTT broker and haven't in a very long time,
I do maintain it still and will try to keep doing so. This is the list of status, current work and priorities:
- The plugin is up to date and is compatible with the recent [2.0 Mosquitto version](https://mosquitto.org/blog/2020/12/version-2-0-0-released/).
- Delayed work on JWT enhancements is almost complete.
- Delayed work on disabling superusers is not yet ready.
- Bug reports will be attended as they appear and will take priority over any work in progress.
- Reviewing ongoing PRs is my next priority.
- Feature enhancements are the lowest priority. Unless they are a super easy win in importance and implementation effort, I'll accept contributions and review
- Feature requests are the lowest priority. Unless they are a super easy win in importance and implementation effort, I'll accept contributions and review
PRs before considering implementing them myself.
Sorry for the noise in the readme, I just wanted to make clear that my plan is to tackle that couple of features and then review the current PRs, hopefully during this month, and then defer everything to next year to try and reduce the backlog during January and February. Now you may continue to read relevant information about the plugin.
@ -35,6 +34,7 @@ These are the backends that this plugin implements right now:
* MongoDB
* Custom (experimental)
* gRPC
* Javascript interpreter
**Every backend offers user, superuser and acl checks, and include proper tests.**
@ -82,6 +82,8 @@ Please open an issue with the `feature` or `enhancement` tag to request new back
- [gRPC](#grpc)
- [Service](#service)
- [Testing gRPC](#testing-grpc)
- [Javascript](#javascript)
- [Testing Javascript](#testing-javascript)
- [Using with LoRa Server](#using-with-lora-server)
- [Docker](#docker)
- [License](#license)
@ -979,7 +981,7 @@ The backend will pass `mosquitto` provided arguments along, that is `token` for
Optionally, `username` will be passed as an argument when `auth_opt_jwt_parse_token` option is set. As with remote mode, this will need `auth_opt_jwt_secret` to be set and correct,
and `auth_opt_jwt_userfield` to be optionally set.
This is a valid, albeit pretty useless, example script for ACL checks (see `test-files` dir for test scripts):
This is a valid, albeit pretty useless, example script for ACL checks (see `test-files/jwt` dir for test scripts):
```
function checkAcl(token, topic, clientid, acc) {
@ -1277,7 +1279,7 @@ As this option is custom written by yourself, there are no tests included in the
### gRPC
The `grpc` allows to check for user auth, superuser and acls against a gRPC service.
The `grpc` backend allows to check for user auth, superuser and acls against a gRPC service.
The following `auth_opt_` options are supported:
@ -1363,6 +1365,61 @@ message NameResponse {
This backend has no special requirements as a gRPC server is mocked to test different scenarios.
### Javascript
The `javascript` backend allows to run a JavaScript interpreter VM to conduct checks. Options for this mode are:
| Option | default | Mandatory | Meaning |
| --------------------------| --------------- | :---------: | ----------------------------------------------------- |
| js_stack_depth_limit | 32 | N | Max stack depth for the interpreter |
| js_ms_max_duration | 200 | N | Max execution time for a hceck in milliseconds |
| js_user_script_path | | Y | Relative or absolute path to user check script |
| js_superuser_script_path | | Y | Relative or absolute path to superuser check script |
| js_acl_script_path | | Y | Relative or absolute path to ACL check script |
This backend expects the user to define JS scripts that return a boolean result to the check in question.
The backend will pass `mosquitto` provided arguments along, that is:
- `username`, `password` and `clientid` for `user` checks.
- `username` for `superuser` checks.
- `username`, `topic`, `clientid` and `acc` for `ACL` checks.
This is a valid, albeit pretty useless, example script for ACL checks (see `test-files/jwt` dir for test scripts):
```
function checkAcl(username, topic, clientid, acc) {
if(username != "correct") {
return false;
}
if(topic != "test/topic") {
return false;
}
if(clientid != "id") {
return false;
}
if(acc != 1) {
return false;
}
return true;
}
checkAcl(username, topic, clientid, acc);
```
#### Password hashing
Notice the `password` will be passed to the script as given by `mosquitto`, leaving any hashing to the script.
#### Testing Javascript
This backend has no special requirements as `javascript` test files are provided to test different scenarios.
### Using with LoRa Server

149
backends/javascript.go Normal file
View File

@ -0,0 +1,149 @@
package backends
import (
"strconv"
"github.com/iegomez/mosquitto-go-auth/backends/js"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
type Javascript struct {
stackDepthLimit int
msMaxDuration int64
userScript string
superuserScript string
aclScript string
runner *js.Runner
}
func NewJavascript(authOpts map[string]string, logLevel log.Level) (*Javascript, error) {
log.SetLevel(logLevel)
javascript := &Javascript{
stackDepthLimit: js.DefaultStackDepthLimit,
msMaxDuration: js.DefaultMsMaxDuration,
}
jsOk := true
missingOptions := ""
if stackLimit, ok := authOpts["js_stack_depth_limit"]; ok {
limit, err := strconv.ParseInt(stackLimit, 10, 64)
if err != nil {
log.Errorf("invalid stack depth limit %s, defaulting to %d", stackLimit, js.DefaultStackDepthLimit)
} else {
javascript.stackDepthLimit = int(limit)
}
}
if maxDuration, ok := authOpts["js_ms_max_duration"]; ok {
duration, err := strconv.ParseInt(maxDuration, 10, 64)
if err != nil {
log.Errorf("invalid stack depth limit %s, defaulting to %d", maxDuration, js.DefaultMsMaxDuration)
} else {
javascript.msMaxDuration = duration
}
}
if userScriptPath, ok := authOpts["js_user_script_path"]; ok {
script, err := js.LoadScript(userScriptPath)
if err != nil {
return javascript, err
}
javascript.userScript = script
} else {
jsOk = false
missingOptions += " js_user_script_path"
}
if superuserScriptPath, ok := authOpts["js_superuser_script_path"]; ok {
script, err := js.LoadScript(superuserScriptPath)
if err != nil {
return javascript, err
}
javascript.superuserScript = script
} else {
jsOk = false
missingOptions += " js_superuser_script_path"
}
if aclScriptPath, ok := authOpts["js_acl_script_path"]; ok {
script, err := js.LoadScript(aclScriptPath)
if err != nil {
return javascript, err
}
javascript.aclScript = script
} else {
jsOk = false
missingOptions += " js_acl_script_path"
}
//Exit if any mandatory option is missing.
if !jsOk {
return nil, errors.Errorf("Javascript backend error: missing options: %s", missingOptions)
}
javascript.runner = js.NewRunner(javascript.stackDepthLimit, javascript.msMaxDuration)
return javascript, nil
}
func (o *Javascript) GetUser(username, password, clientid string) bool {
params := map[string]interface{}{
"username": username,
"password": password,
"clientid": clientid,
}
granted, err := o.runner.RunScript(o.userScript, params)
if err != nil {
log.Errorf("js error: %s", err)
}
return granted
}
func (o *Javascript) GetSuperuser(username string) bool {
params := map[string]interface{}{
"username": username,
}
granted, err := o.runner.RunScript(o.superuserScript, params)
if err != nil {
log.Errorf("js error: %s", err)
}
return granted
}
func (o *Javascript) CheckAcl(username, topic, clientid string, acc int32) bool {
params := map[string]interface{}{
"username": username,
"topic": topic,
"clientid": clientid,
"acc": acc,
}
granted, err := o.runner.RunScript(o.aclScript, params)
if err != nil {
log.Errorf("js error: %s", err)
}
return granted
}
//GetName returns the backend's name
func (o *Javascript) GetName() string {
return "Javascript"
}
func (o *Javascript) Halt() {
// NO-OP
}

View File

@ -0,0 +1,78 @@
package backends
import (
"testing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)
func TestJavascript(t *testing.T) {
authOpts := make(map[string]string)
authOpts["js_user_script_path"] = "../test-files/js/user_script.js"
authOpts["js_superuser_script_path"] = "../test-files/js/superuser_script.js"
authOpts["js_acl_script_path"] = "../test-files/js/acl_script.js"
Convey("When constructing a Javascript backend", t, func() {
Convey("It returns error if there's a missing option", func() {
badOpts := make(map[string]string)
badOpts["js_user_script"] = authOpts["js_user_script"]
badOpts["js_superuser_script"] = authOpts["js_superuser_script"]
_, err := NewJavascript(badOpts, log.DebugLevel)
So(err, ShouldNotBeNil)
})
Convey("It returns error if a script can't be opened", func() {
badOpts := make(map[string]string)
badOpts["js_user_script"] = authOpts["js_user_script"]
badOpts["js_superuser_script"] = authOpts["js_superuser_script"]
badOpts["js_acl_script_path"] = "../test-files/js/nothing_here.js"
_, err := NewJavascript(badOpts, log.DebugLevel)
So(err, ShouldNotBeNil)
})
javascript, err := NewJavascript(authOpts, log.DebugLevel)
So(err, ShouldBeNil)
Convey("User checks should work", func() {
userResponse := javascript.GetUser("correct", "good", "some-id")
So(userResponse, ShouldBeTrue)
userResponse = javascript.GetUser("correct", "bad", "some-id")
So(userResponse, ShouldBeFalse)
userResponse = javascript.GetUser("wrong", "good", "some-id")
So(userResponse, ShouldBeFalse)
})
Convey("Superuser checks should work", func() {
superuserResponse := javascript.GetSuperuser("admin")
So(superuserResponse, ShouldBeTrue)
superuserResponse = javascript.GetSuperuser("non-admin")
So(superuserResponse, ShouldBeFalse)
})
Convey("ACL checks should work", func() {
aclResponse := javascript.CheckAcl("correct", "test/topic", "id", 1)
So(aclResponse, ShouldBeTrue)
aclResponse = javascript.CheckAcl("incorrect", "test/topic", "id", 1)
So(aclResponse, ShouldBeFalse)
aclResponse = javascript.CheckAcl("correct", "bad/topic", "id", 1)
So(aclResponse, ShouldBeFalse)
aclResponse = javascript.CheckAcl("correct", "test/topic", "wrong-id", 1)
So(aclResponse, ShouldBeFalse)
aclResponse = javascript.CheckAcl("correct", "test/topic", "id", 2)
So(aclResponse, ShouldBeFalse)
})
})
}

View File

@ -8,6 +8,12 @@ import (
"github.com/robertkrimen/otto"
)
// Default conf values for runner.
const (
DefaultStackDepthLimit = 32
DefaultMsMaxDuration = 200
)
type Runner struct {
StackDepthLimit int
MsMaxDuration int64

View File

@ -21,22 +21,17 @@ type jsJWTChecker struct {
runner *js.Runner
}
const (
defaultStackDepthLimit = 32
defaultMsMaxDuration = 200
)
func NewJsJWTChecker(authOpts map[string]string, options tokenOptions) (jwtChecker, error) {
checker := &jsJWTChecker{
stackDepthLimit: defaultStackDepthLimit,
msMaxDuration: defaultMsMaxDuration,
stackDepthLimit: js.DefaultStackDepthLimit,
msMaxDuration: js.DefaultMsMaxDuration,
options: options,
}
if stackLimit, ok := authOpts["jwt_js_stack_depth_limit"]; ok {
limit, err := strconv.ParseInt(stackLimit, 10, 64)
if err != nil {
log.Errorf("invalid stack depth limit %s, defaulting to 32", stackLimit)
log.Errorf("invalid stack depth limit %s, defaulting to %d", stackLimit, js.DefaultStackDepthLimit)
} else {
checker.stackDepthLimit = int(limit)
}
@ -45,7 +40,7 @@ func NewJsJWTChecker(authOpts map[string]string, options tokenOptions) (jwtCheck
if maxDuration, ok := authOpts["jwt_js_ms_max_duration"]; ok {
duration, err := strconv.ParseInt(maxDuration, 10, 64)
if err != nil {
log.Errorf("invalid stack depth limit %s, defaulting to 32", maxDuration)
log.Errorf("invalid stack depth limit %s, defaulting to %d", maxDuration, js.DefaultMsMaxDuration)
} else {
checker.msMaxDuration = duration
}

View File

@ -107,9 +107,9 @@ func TestJWTClaims(t *testing.T) {
func TestJsJWTChecker(t *testing.T) {
authOpts := make(map[string]string)
authOpts["jwt_js_user_script_path"] = "../test-files/js_user_script.js"
authOpts["jwt_js_superuser_script_path"] = "../test-files/js_superuser_script.js"
authOpts["jwt_js_acl_script_path"] = "../test-files/js_acl_script.js"
authOpts["jwt_js_user_script_path"] = "../test-files/jwt/user_script.js"
authOpts["jwt_js_superuser_script_path"] = "../test-files/jwt/superuser_script.js"
authOpts["jwt_js_acl_script_path"] = "../test-files/jwt/acl_script.js"
Convey("Creating a js checker should succeed", t, func() {
checker, err := NewJsJWTChecker(authOpts, tkOptions)
@ -149,7 +149,7 @@ func TestJsJWTChecker(t *testing.T) {
userField: "Username",
}
authOpts["jwt_js_user_script_path"] = "../test-files/js_parsed_user_script.js"
authOpts["jwt_js_user_script_path"] = "../test-files/jwt/parsed_user_script.js"
checker, err = NewJsJWTChecker(authOpts, jsTokenOptions)
So(err, ShouldBeNil)

View File

@ -57,6 +57,7 @@ const (
mongoBackend = "mongo"
pluginBackend = "plugin"
grpcBackend = "grpc"
jsBackend = "js"
)
// Serves s a check for allowed backends and a map from backend to expected opts prefix.
@ -71,6 +72,7 @@ var allowedBackendsOptsPrefix = map[string]string{
mongoBackend: "mongo",
pluginBackend: "plugin",
grpcBackend: "grpc",
jsBackend: "js",
}
var backends []string //List of selected backends.
@ -327,6 +329,14 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
log.Infof("Backend registered: %s", beIface.GetName())
cmBackends[grpcBackend] = beIface.(bes.GRPC)
}
case jsBackend:
beIface, err = bes.NewJavascript(authOpts, authPlugin.logLevel)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
log.Infof("Backend registered: %s", beIface.GetName())
cmBackends[jsBackend] = beIface.(*bes.Javascript)
}
}
}
}

View File

@ -0,0 +1,21 @@
function checkAcl(username, topic, clientid, acc) {
if(username != "correct") {
return false;
}
if(topic != "test/topic") {
return false;
}
if(clientid != "id") {
return false;
}
if(acc != 1) {
return false;
}
return true;
}
checkAcl(username, topic, clientid, acc);

View File

@ -0,0 +1,8 @@
function checkSuperuser(username) {
if(username == "admin") {
return true;
}
return false;
}
checkSuperuser(username);

View File

@ -0,0 +1,8 @@
function checkUser(username, password, clientid) {
if(username == "correct" && password == "good") {
return true;
}
return false;
}
checkUser(username, password, clientid);