Refactor hashing: add support for bcrypt and argond2id hashers.

Fix cache security issue.
This commit is contained in:
Ignacio Gómez 2020-06-28 04:15:17 -04:00
parent f5a5cec554
commit aa487a9a05
No known key found for this signature in database
GPG Key ID: 15A77C6BEC604B06
30 changed files with 925 additions and 373 deletions

8
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,8 @@
# This is a comment.
# Each line is a file pattern followed by one or more owners.
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
# @global-owner1 and @global-owner2 will be requested for
# review when someone opens a pull request.
* @iegomez

View File

@ -8,7 +8,7 @@ dev-requirements:
go get -u github.com/smartystreets/goconvey
test:
go test ./backends ./cache -v -bench=none -count=1
go test ./backends ./cache ./hashing -v -bench=none -count=1
benchmark:
go test ./backends -v -bench=. -run=^a

151
README.md
View File

@ -4,11 +4,11 @@ Mosquitto Go Auth is an authentication and authorization plugin for the Mosquitt
### Intro
This is an authentication and authorization plugin for [mosquitto](https://mosquitto.org/), a well known open source MQTT broker. It's written (almost) entirely in Go: it uses cgo to expose mosquitto's auth plugin needed functions, but internally just calls Go to get everything done.
This is an authentication and authorization plugin for [mosquitto](https://mosquitto.org/), a well known open source MQTT broker. It's written (almost) entirely in Go: it uses `cgo` to expose mosquitto's auth plugin needed functions, but internally just calls Go to get everything done.
It is greatly inspired in [jpmens'](https://github.com/jpmens) [mosquitto-auth-plug](https://github.com/jpmens/mosquitto-auth-plug).
It was intended for use with [brocaar's](https://github.com/brocaar) [Loraserver project](https://www.loraserver.io/), and thus Files, Postgres and JWT backends were the first to be developed, but more have been added. These are the backends that this plugin implements right now:
These are the backends that this plugin implements right now:
* Files
* PostgreSQL
@ -35,6 +35,7 @@ Please open an issue with the `feature` or `enhancement` tag to request new back
- [Configuration](#configuration)
- [General options](#general-options)
- [Cache](#cache)
- [Hashing](#hashing)
- [Log level](#log-level)
- [Prefixes](#prefixes)
- [Backend options](#backend-options)
@ -76,23 +77,23 @@ Please open an issue with the `feature` or `enhancement` tag to request new back
### Requirements
This package uses `Go modules` to manage dependencies, `dep` is no longer supported.
As it interacts with mosquitto, it makes use of Cgo. Also, it (optionally) uses Redis for cache purposes.
As it interacts with `mosquitto`, it makes use of `cgo`. Also, it (optionally) uses Redis for cache purposes.
### Build
Before building, you need to build mosquitto. For completeness, we'll build it with websockets, ssl and srv support.
Before building, you need to build `mosquitto`. For completeness, we'll build it with `websockets`, `tls` and `srv` support.
First, install dependencies (tested on Debian 9 and later, Linux Mint 18 and 19):
First, install dependencies (tested on Debian 9 and later, Linux Mint 18, 19 and 20):
`sudo apt-get install libwebsockets8 libwebsockets-dev libc-ares2 libc-ares-dev openssl uuid uuid-dev`
`sudo apt-get install libwebsockets8 libwebsockets-dev libc-ares2 libc-ares-dev openssl uuid uuid-dev`
Download mosquitto and extract it (**change versions accordingly**):
```
wget http://mosquitto.org/files/source/mosquitto-1.6.8.tar.gz
tar xzvf mosquitto-1.6.8.tar.gz
cd mosquitto-1.6.8
tar xzvf mosquitto-1.6.10.tar.gz
cd mosquitto-1.6.10
```
Modify config.mk, setting websockets support. Then build mosquitto, add a mosquitto user and set ownership for /var/log/mosquitto and /var/lib/mosquitto/ (default log and persistence locations).
@ -278,6 +279,61 @@ auth_opt_cache_addresses host1:port1,host2:port2,host3:port3
Notice that if `cache_mode` is not provided or isn't equal to `cluster`, cache will default to use a single instance with the common options. If instead the mode is set to `cluster` but no addresses are given, the plugin will default to not use a cache.
#### Hashing
There are 3 options for password hashing available: `PBKDF2` (default), `Bcrypt` and `Argon2ID`. Every backend that needs one -that's all but `grpc`, `http` and `custom`- gets a hasher and whether it uses specific options or general ones depends on the auth opts passed.
Provided options define what hasher each backend will use:
- If there are general hashing options available but no backend ones, then every backend will use those general ones for its hasher.
- If there are no options available in general and none for a given backend either, that backend will use defaults (see `hashing/hashing.go` for default values).
- If there are options for a given backend but no general ones, the backend will use its own hasher and any backend that doesn't register a hasher will use defaults.
You may set the desired general hasher with this option, passing either `pbkdf2`, `bcrypt` or `argon2id` values. When not set, the option will default to `pbkdf2`.
```
auth_opt_hasher pbkdf2
```
Each hasher has specific options. Notice that when using the `pw` utility, these values must match those used to generate the password.
##### PBKDF2
```
auth_opt_salt_size 16 # salt bytes length
auth_opt_iterations 100000 # number of iterations
auth_opt_keylen 64 # key length
auth_opt_algorithm sha512 # hashing algorithm, either sha512 (default) or sha256
auth_opt_salt_encoding # salt encoding, either base64 (default) or utf-8
```
##### Bcrypt
```
auth_opt_cost 10 # key expansion iteration count
```
##### Argon2ID
```
auth_opt_salt_size 16 # salt bytes length
auth_opt_iterations 3 # number of iterations
auth_opt_keylen 64 # key length
auth_opt_memory 4096 # amount of memory (in kibibytes) to use
auth_opt_parallelism 2 # degree of parallelism (i.e. number of threads)
```
**These options may be defined for each backend that needs a hasher by prepending the backend's name to the option, e.g. for setting `argon2id` as `Postgres'` hasher**:
```
auth_opt_pg_hasher argon2id
auth_opt_pg_salt_size 16 # salt bytes length
auth_opt_pg_iterations 3 # number of iterations
auth_opt_pg_keylen 64 # key length
auth_opt_pg_memory 4096 # amount of memory (in kibibytes) to use
auth_opt_pg_parallelism # degree of parallelism (i.e. number of threads)
```
#### Logging
You can set the log level with the `log_level` option. Valid values are: debug, info, warn, error, fatal and panic. If not set, default value is `info`.
@ -297,7 +353,7 @@ If `log_dest` or `log_file` are invalid, or if there's an error opening the file
#### Prefixes
Though the plugin may have multiple backends enabled, there's a way to specify which backend must be used for a given user: prefixes. When enabled, `prefixes` allows to check if the username contains a predefined prefix in the form prefix_username and use the configured backend for that prefix. Options to enable and set prefixes are the following:
Though the plugin may have multiple backends enabled, there's a way to specify which backend must be used for a given user: prefixes. When enabled, `prefixes` allow to check if the username contains a predefined prefix in the form prefix_username and use the configured backend for that prefix. Options to enable and set prefixes are the following:
```
auth_opt_check_prefix true
@ -319,6 +375,38 @@ auth_opt_disable_superuser true
Any other value or missing option will have `superuser` enabled.
#### ACL access values
Mosquitto 1.5 introduced a new ACL access value, `MOSQ_ACL_SUBSCRIBE`, which is similar to the classic `MOSQ_ACL_READ` value but not quite the same:
```
* MOSQ_ACL_SUBSCRIBE when a client is asking to subscribe to a topic string.
* This differs from MOSQ_ACL_READ in that it allows you to
* deny access to topic strings rather than by pattern. For
* example, you may use MOSQ_ACL_SUBSCRIBE to deny
* subscriptions to '#', but allow all topics in
* MOSQ_ACL_READ. This allows clients to subscribe to any
* topic they want, but not discover what topics are in use
* on the server.
* MOSQ_ACL_READ when a message is about to be sent to a client (i.e. whether
* it can read that topic or not).
```
The main difference is that subscribe is checked at first, when a client connects and tells the broker it wants to subscribe to some topic, while read is checked when an actual message is being published to that topic, which makes it particular.
So in practice you could deny general subscriptions such as # by returning false from the acl check when you receive `MOSQ_ACL_SUBSCRIBE`, but allow any particular one by returning true on `MOSQ_ACL_READ`.
Please take this into consideration when designing your ACL records on every backend.
Also, these are the current available values from `mosquitto`:
```
#define MOSQ_ACL_NONE 0x00
#define MOSQ_ACL_READ 0x01
#define MOSQ_ACL_WRITE 0x02
#define MOSQ_ACL_SUBSCRIBE 0x04
```
If you're using prior versions then `MOSQ_ACL_SUBSCRIBE` is not available and you don't need to worry about it.
#### Backend options
Any other options with a leading ```auth_opt_``` are handed to the plugin and used by the backends.
@ -333,7 +421,7 @@ This issue captures these concerns and a basic plan to refactor tests: https://g
### Files
The `files` backend implements the regular password and acl checks as described in mosquitto. Passwords should be in PBKDF2 format (for other backends too), and may be generated using the `pw` utility (built by default when running `make`) included in the plugin (or one of your own). Passwords may also be tested using the [pw-test package](https://github.com/iegomez/pw-test).
The `files` backend implements the regular password and acl checks as described in mosquitto. Passwords should be in `PBKDF2`, `Bcrypt` or `Argon2ID` format (for other backends too), see [Hashing](#hashing) for more details about different hashing strategies. Hashes may be generated using the `pw` utility (built by default when running `make`) included in the plugin (or one of your own). Passwords may also be tested using the [pw-test package](https://github.com/iegomez/pw-test).
Usage of `pw`:
@ -341,15 +429,28 @@ Usage of `pw`:
Usage of ./pw:
-a string
algorithm: sha256 or sha512 (default "sha512")
-c int
bcrypt ost param (default 10)
-e string
salt encoding (default "base64")
-h string
hasher: pbkdf2, argon2 or bcrypt (default "pbkdf2")
-i int
hash iterations (default 100000)
hash iterations: defaults to 100000 for pbkdf2, please set to a reasonable value for argon2 (default 100000)
-l int
key length, recommended values are 32 for sha256 and 64 for sha512
-m int
memory for argon2 hash (default 4096)
-p string
password
-pl int
parallelism for argon2 (default 2)
-s int
salt size (default 16)
```
For this backend passwords and acls file paths must be given:
For this backend `passwords` and `acls` file paths must be given:
```
auth_opt_password_path /path/to/password_file
@ -455,7 +556,7 @@ Queries work pretty much the same as in jpmen's plugin, so here's his discriptio
In the following example, the table has a column `rw` containing 1 for
readonly topics, 2 for writeonly topics and 3 for readwrite topics:
SELECT topic FROM acl WHERE (username = $1) AND (rw = $2 or rw = 3)
SELECT topic FROM acl WHERE (username = $1) AND rw = $2
When option pg_superquery is not present, Superuser check will always return false, hence there'll be no superusers.
@ -476,6 +577,9 @@ auth_opt_pg_aclquery select distinct 'application/' || a.id || '/#' from "user"
```
#### Password hashing
For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing).
#### Testing Postgres
@ -527,7 +631,7 @@ To allow native passwords, set the option to true:
auth_opt_mysql_allow_native_passwords true
```
Finally, placeholders for mysql differ from those of postgres, changing from $1, $2, etc., to simply ?. So, following the postgres examples, same queries for mysql would look like these:
Finally, placeholders for mysql differ from those of postgres, changing from $1, $2, etc., to simply ?. These are some **example** queries for `mysql`:
User query:
@ -545,9 +649,12 @@ SELECT COUNT(*) FROM account WHERE username = ? AND super = 1
Acl query:
```sql
SELECT topic FROM acl WHERE (username = ?) AND rw >= ?
SELECT topic FROM acl WHERE (username = ?) AND rw = ?
```
#### Password hashing
For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing).
#### Testing Mysql
@ -616,6 +723,9 @@ sqlite_superquery SELECT COUNT(*) FROM account WHERE username = ? AND super = 1
sqlite_aclquery SELECT topic FROM acl WHERE (username = ?) AND rw >= ?
```
#### Password hashing
For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing).
#### Testing SQLite3
@ -813,6 +923,9 @@ When option jwt_superquery is not present, Superuser check will always return fa
When option jwt_aclquery is not present, AclCheck will always return true, hence all authenticated users will be authorized to pub/sub to any topic.
#### Password hashing
When using local mode, a hasher is expected. For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing).
#### Prefixes
@ -911,6 +1024,10 @@ When not present, host defaults to "localhost", port to 6379, db to 2 and no pas
If you want to use a Redis Cluster as your backend, you need to set `auth_opt_redis_mode` to `cluster` and provide the different addresses as a list of comma separated `host:port` strings with the `auth_opt_redis_addresses` options.
If `auth_opt_redis_mode` is set to another value or not set, Redis defaults to single instance behaviour. If it is correctly set but no addresses are given, the backend will fail to initialize.
#### Password hashing
For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing).
#### Testing Redis
In order to test the Redis backend, the plugin needs to be able to connect to a redis server located at localhost, on port 6379, without using password and that a database named 2 exists (to avoid messing with the commonly used 0 and 1).
@ -990,6 +1107,10 @@ When not set, these options default to:
If you experience any problem connecting to a replica set, please refer to [this issue](https://github.com/iegomez/mosquitto-go-auth/issues/32).
#### Password hashing
For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing).
#### Testing MongoDB
Much like `redis`, to test this backend the plugin needs to be able to connect to a mongodb server located at localhost, on port 27017, without using username or password.

31
backends/db.go Normal file
View File

@ -0,0 +1,31 @@
package backends
import (
"time"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// OpenDatabase opens the database and performs a ping to make sure the
// database is up.
// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server
func OpenDatabase(dsn, engine string) (*sqlx.DB, error) {
db, err := sqlx.Open(engine, dsn)
if err != nil {
return nil, errors.Wrap(err, "database connection error")
}
for {
if err = db.Ping(); err != nil {
log.Errorf("ping database error, will retry in 2s: %s", err)
time.Sleep(2 * time.Second)
} else {
break
}
}
return db, nil
}

View File

@ -6,14 +6,11 @@ import (
"os"
"strings"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// hashIterations defines the number of hash iterations.
var hashIterations = 100000
//FileUer keeps a user password and acl records.
type FileUser struct {
Password string
@ -30,15 +27,15 @@ type AclRecord struct {
type Files struct {
PasswordPath string
AclPath string
SaltEncoding 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
}
//NewFiles initializes a files backend.
func NewFiles(authOpts map[string]string, logLevel log.Level) (Files, error) {
func NewFiles(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Files, error) {
log.SetLevel(logLevel)
@ -48,8 +45,8 @@ func NewFiles(authOpts map[string]string, logLevel log.Level) (Files, error) {
CheckAcls: false,
Users: make(map[string]*FileUser),
AclRecords: make([]AclRecord, 0),
SaltEncoding: "base64",
filesOnly: true,
hasher: hasher,
}
if len(strings.Split(strings.Replace(authOpts["backends"], " ", "", -1), ",")) > 1 {
@ -62,16 +59,6 @@ func NewFiles(authOpts map[string]string, logLevel log.Level) (Files, error) {
return files, errors.New("Files backend error: no password path given")
}
if saltEncoding, ok := authOpts["salt_encoding"]; ok {
switch saltEncoding {
case common.Base64, common.UTF8:
files.SaltEncoding = saltEncoding
log.Debugf("files backend: set salt encoding to: %s", saltEncoding)
default:
log.Errorf("files backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding)
}
}
if aclPath, ok := authOpts["acl_path"]; ok {
files.AclPath = aclPath
files.CheckAcls = true
@ -306,7 +293,7 @@ func (o Files) GetUser(username, password, clientid string) bool {
return false
}
if common.HashCompare(password, fileUser.Password, o.SaltEncoding) {
if o.hasher.Compare(password, fileUser.Password) {
return true
}
@ -334,7 +321,7 @@ func (o Files) CheckAcl(username, topic, clientid string, acc int32) bool {
//If user exists, check against his acls and common ones. If not, check against common acls only.
if ok {
for _, aclRecord := range fileUser.AclRecords {
if common.TopicsMatch(aclRecord.Topic, topic) && (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))) {
if TopicsMatch(aclRecord.Topic, topic) && (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
}
}
@ -343,7 +330,7 @@ func (o Files) CheckAcl(username, topic, clientid string, acc int32) bool {
//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)
if common.TopicsMatch(aclTopic, topic) && (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))) {
if TopicsMatch(aclTopic, topic) && (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
}
}

View File

@ -4,6 +4,7 @@ import (
"path/filepath"
"testing"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)
@ -14,7 +15,7 @@ func TestFiles(t *testing.T) {
authOpts := make(map[string]string)
Convey("Given empty opts NewFiles should fail", t, func() {
_, err := NewFiles(authOpts, log.DebugLevel)
_, err := NewFiles(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "files"))
So(err, ShouldBeError)
})
@ -25,7 +26,7 @@ func TestFiles(t *testing.T) {
clientID := "test_client"
Convey("Given valid params NewFiles should return a new files backend instance", t, func() {
files, err := NewFiles(authOpts, log.DebugLevel)
files, err := NewFiles(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "files"))
So(err, ShouldBeNil)
/*

View File

@ -6,12 +6,10 @@ import (
"testing"
"github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc"
log "github.com/sirupsen/logrus"
gs "github.com/iegomez/mosquitto-go-auth/grpc"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
"google.golang.org/grpc"
)
const (

View File

@ -10,7 +10,6 @@ import (
"testing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)

View File

@ -14,7 +14,8 @@ import (
"strings"
"time"
jwt "github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go"
"github.com/iegomez/mosquitto-go-auth/hashing"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
@ -44,6 +45,8 @@ type JWT struct {
UserField string
Client *h.Client
hasher hashing.HashComparer
}
// Claims defines the struct containing the token claims. StandardClaim's Subject field should contain the username, unless an opt is set to support Username field.
@ -58,7 +61,7 @@ type Response struct {
Error string `json:"error"`
}
func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) {
func NewJWT(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (JWT, error) {
log.SetLevel(logLevel)
@ -71,6 +74,7 @@ func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) {
ParamsMode: "json",
LocalDB: "postgres",
UserField: "Subject",
hasher: hasher,
}
if userField, ok := authOpts["jwt_userfield"]; ok && userField == "Username" {
@ -190,7 +194,7 @@ func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) {
if jwt.LocalDB == "mysql" {
//Try to create a mysql backend with these custom queries
mysql, err := NewMysql(authOpts, logLevel)
mysql, err := NewMysql(authOpts, logLevel, hasher)
if err != nil {
return jwt, errors.Errorf("JWT backend error: couldn't create mysql connector for local jwt: %s", err)
}
@ -201,7 +205,7 @@ func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) {
jwt.Mysql = mysql
} else {
//Try to create a postgres backend with these custom queries.
postgres, err := NewPostgres(authOpts, logLevel)
postgres, err := NewPostgres(authOpts, logLevel, hasher)
if err != nil {
return jwt, errors.Errorf("JWT backend error: couldn't create postgres connector for local jwt: %s", err)
}

View File

@ -10,9 +10,9 @@ import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
jwt "github.com/dgrijalva/jwt-go"
. "github.com/smartystreets/goconvey/convey"
)
@ -73,7 +73,7 @@ func TestLocalPostgresJWT(t *testing.T) {
authOpts["pg_password"] = "go_auth_test"
Convey("Given correct option NewJWT returns an instance of jwt backend", func() {
jwt, err := NewJWT(authOpts, log.DebugLevel)
jwt, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
//Empty db
@ -222,7 +222,7 @@ func TestLocalMysqlJWT(t *testing.T) {
authOpts["mysql_allow_native_passwords"] = "true"
Convey("Given correct option NewJWT returns an instance of jwt backend", func() {
jwt, err := NewJWT(authOpts, log.DebugLevel)
jwt, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
//Empty db
@ -451,7 +451,7 @@ func TestJWTAllJsonServer(t *testing.T) {
authOpts["jwt_aclcheck_uri"] = "/acl"
Convey("Given correct options an http backend instance should be returned", t, func() {
hb, err := NewJWT(authOpts, log.DebugLevel)
hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
Convey("Given correct password/username, get user should return true", func() {
@ -580,7 +580,7 @@ func TestJWTJsonStatusOnlyServer(t *testing.T) {
authOpts["jwt_aclcheck_uri"] = "/acl"
Convey("Given correct options an http backend instance should be returned", t, func() {
hb, err := NewJWT(authOpts, log.DebugLevel)
hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
Convey("Given correct password/username, get user should return true", func() {
@ -711,7 +711,7 @@ func TestJWTJsonTextResponseServer(t *testing.T) {
authOpts["jwt_aclcheck_uri"] = "/acl"
Convey("Given correct options an http backend instance should be returned", t, func() {
hb, err := NewJWT(authOpts, log.DebugLevel)
hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
Convey("Given correct password/username, get user should return true", func() {
@ -852,7 +852,7 @@ func TestJWTFormJsonResponseServer(t *testing.T) {
authOpts["jwt_aclcheck_uri"] = "/acl"
Convey("Given correct options an http backend instance should be returned", t, func() {
hb, err := NewJWT(authOpts, log.DebugLevel)
hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
Convey("Given correct password/username, get user should return true", func() {
@ -975,7 +975,7 @@ func TestJWTFormStatusOnlyServer(t *testing.T) {
authOpts["jwt_aclcheck_uri"] = "/acl"
Convey("Given correct options an http backend instance should be returned", t, func() {
hb, err := NewJWT(authOpts, log.DebugLevel)
hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
Convey("Given correct password/username, get user should return true", func() {
@ -1101,7 +1101,7 @@ func TestJWTFormTextResponseServer(t *testing.T) {
authOpts["jwt_aclcheck_uri"] = "/acl"
Convey("Given correct options an http backend instance should be returned", t, func() {
hb, err := NewJWT(authOpts, log.DebugLevel)
hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, ""))
So(err, ShouldBeNil)
Convey("Given correct password/username, get user should return true", func() {

View File

@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"go.mongodb.org/mongo-driver/bson"
@ -26,6 +26,7 @@ type Mongo struct {
AclsCollection string
Conn *mongo.Client
disableSuperuser bool
hasher hashing.HashComparer
}
type MongoAcl struct {
@ -40,7 +41,7 @@ type MongoUser struct {
Acls []MongoAcl `bson:"acls"`
}
func NewMongo(authOpts map[string]string, logLevel log.Level) (Mongo, error) {
func NewMongo(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Mongo, error) {
log.SetLevel(logLevel)
@ -53,7 +54,7 @@ func NewMongo(authOpts map[string]string, logLevel log.Level) (Mongo, error) {
AuthSource: "",
UsersCollection: "users",
AclsCollection: "acls",
SaltEncoding: "base64",
hasher: hasher,
}
if authOpts["mongo_disable_superuser"] == "true" {
@ -76,16 +77,6 @@ func NewMongo(authOpts map[string]string, logLevel log.Level) (Mongo, error) {
m.Password = mongoPassword
}
if saltEncoding, ok := authOpts["mongo_salt_encoding"]; ok {
switch saltEncoding {
case common.Base64, common.UTF8:
m.SaltEncoding = saltEncoding
log.Debugf("mongo backend: set salt encoding to: %s", saltEncoding)
default:
log.Errorf("mongo backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding)
}
}
if mongoDBName, ok := authOpts["mongo_dbname"]; ok {
m.DBName = mongoDBName
}
@ -149,7 +140,7 @@ func (o Mongo) GetUser(username, password, clientid string) bool {
return false
}
if common.HashCompare(password, user.PasswordHash, o.SaltEncoding) {
if o.hasher.Compare(password, user.PasswordHash) {
return true
}
@ -193,7 +184,7 @@ func (o Mongo) CheckAcl(username, topic, clientid string, acc int32) bool {
}
for _, acl := range user.Acls {
if (acl.Acc == acc || acl.Acc == 3) && common.TopicsMatch(acl.Topic, topic) {
if (acl.Acc == acc || acl.Acc == 3) && TopicsMatch(acl.Topic, topic) {
return true
}
}
@ -216,7 +207,7 @@ func (o Mongo) CheckAcl(username, topic, clientid string, acc int32) bool {
if err == nil {
aclTopic := strings.Replace(acl.Topic, "%c", clientid, -1)
aclTopic = strings.Replace(aclTopic, "%u", username, -1)
if common.TopicsMatch(aclTopic, topic) {
if TopicsMatch(aclTopic, topic) {
return true
}
} else {

View File

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)
@ -40,7 +41,7 @@ func TestMongoRaw(t *testing.T) {
Convey("Given valid params NewMongo should return a Mongo backend instance", t, func() {
mongo, err := NewMongo(authOpts, log.DebugLevel)
mongo, err := NewMongo(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mongo"))
So(err, ShouldBeNil)
mongo.Conn.Database(mongo.DBName).Drop(context.TODO())
mongoDb := mongo.Conn.Database(mongo.DBName)
@ -195,11 +196,14 @@ func TestMongoUtf8(t *testing.T) {
authOpts["mongo_host"] = mongoHost
authOpts["mongo_port"] = mongoPort
authOpts["mongo_dbname"] = mongoDbName
authOpts["mongo_salt_encoding"] = "utf-8"
// Pass explicit hasher so utf-8 salt encoding is used.
authOpts["hasher"] = "pbkdf2"
authOpts["hasher_salt_encoding"] = "utf-8"
Convey("Given valid params NewMongo should return a Mongo backend instance", t, func() {
mongo, err := NewMongo(authOpts, log.DebugLevel)
mongo, err := NewMongo(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mongo"))
So(err, ShouldBeNil)
mongo.Conn.Database(mongo.DBName).Drop(context.TODO())
mongoDb := mongo.Conn.Database(mongo.DBName)

View File

@ -9,7 +9,7 @@ import (
"strings"
mq "github.com/go-sql-driver/mysql"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
@ -23,7 +23,6 @@ type Mysql struct {
DBName string
User string
Password string
SaltEncoding string
UserQuery string
SuperuserQuery string
AclQuery string
@ -34,9 +33,10 @@ type Mysql struct {
Protocol string
SocketPath string
AllowNativePasswords bool
hasher hashing.HashComparer
}
func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) {
func NewMysql(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Mysql, error) {
log.SetLevel(logLevel)
@ -52,7 +52,7 @@ func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) {
SuperuserQuery: "",
AclQuery: "",
Protocol: "tcp",
SaltEncoding: "base64",
hasher: hasher,
}
if protocol, ok := authOpts["mysql_protocol"]; ok {
@ -92,16 +92,6 @@ func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) {
missingOptions += " mysql_password"
}
if saltEncoding, ok := authOpts["mysql_salt_encoding"]; ok {
switch saltEncoding {
case common.Base64, common.UTF8:
mysql.SaltEncoding = saltEncoding
log.Debugf("mysql backend: set salt encoding to: %s", saltEncoding)
default:
log.Errorf("mysql backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding)
}
}
if userQuery, ok := authOpts["mysql_userquery"]; ok {
mysql.UserQuery = userQuery
} else {
@ -201,7 +191,7 @@ func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) {
}
var err error
mysql.DB, err = common.OpenDatabase(msConfig.FormatDSN(), "mysql")
mysql.DB, err = OpenDatabase(msConfig.FormatDSN(), "mysql")
if err != nil {
return mysql, errors.Errorf("MySql backend error: couldn't open db: %s", err)
@ -227,7 +217,7 @@ func (o Mysql) GetUser(username, password, clientid string) bool {
return false
}
if common.HashCompare(password, pwHash.String, o.SaltEncoding) {
if o.hasher.Compare(password, pwHash.String) {
return true
}
@ -283,7 +273,7 @@ func (o Mysql) CheckAcl(username, topic, clientid string, acc int32) bool {
for _, acl := range acls {
aclTopic := strings.Replace(acl, "%c", clientid, -1)
aclTopic = strings.Replace(aclTopic, "%u", username, -1)
if common.TopicsMatch(aclTopic, topic) {
if TopicsMatch(aclTopic, topic) {
return true
}
}

View File

@ -3,6 +3,7 @@ package backends
import (
"testing"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)
@ -17,7 +18,7 @@ func TestMysql(t *testing.T) {
authOpts["mysql_allow_native_passwords"] = "true"
Convey("If mandatory params are not set initialization should fail", t, func() {
_, err := NewMysql(authOpts, log.DebugLevel)
_, err := NewMysql(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mysql"))
So(err, ShouldBeError)
})
@ -30,7 +31,7 @@ func TestMysql(t *testing.T) {
authOpts["mysql_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = ? AND test_acl.test_user_id = test_user.id AND (rw >= ? or rw = 3)"
Convey("Given valid params NewMysql should return a Mysql backend instance", t, func() {
mysql, err := NewMysql(authOpts, log.DebugLevel)
mysql, err := NewMysql(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mysql"))
So(err, ShouldBeNil)
//Empty db

View File

@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/pkg/errors"
@ -20,7 +20,6 @@ type Postgres struct {
DBName string
User string
Password string
SaltEncoding string
UserQuery string
SuperuserQuery string
AclQuery string
@ -28,9 +27,10 @@ type Postgres struct {
SSLCert string
SSLKey string
SSLRootCert string
hasher hashing.HashComparer
}
func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, error) {
func NewPostgres(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Postgres, error) {
log.SetLevel(logLevel)
@ -45,7 +45,7 @@ func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, erro
SSLMode: "disable",
SuperuserQuery: "",
AclQuery: "",
SaltEncoding: "base64",
hasher: hasher,
}
if host, ok := authOpts["pg_host"]; ok {
@ -77,16 +77,6 @@ func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, erro
missingOptions += " pg_password"
}
if saltEncoding, ok := authOpts["pg_salt_encoding"]; ok {
switch saltEncoding {
case common.Base64, common.UTF8:
postgres.SaltEncoding = saltEncoding
log.Debugf("postgres backend: set salt encoding to: %s", saltEncoding)
default:
log.Errorf("postgres backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding)
}
}
if userQuery, ok := authOpts["pg_userquery"]; ok {
postgres.UserQuery = userQuery
} else {
@ -145,7 +135,7 @@ func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, erro
}
var err error
postgres.DB, err = common.OpenDatabase(connStr, "postgres")
postgres.DB, err = OpenDatabase(connStr, "postgres")
if err != nil {
return postgres, errors.Errorf("PG backend error: couldn't open db: %s", err)
@ -171,7 +161,7 @@ func (o Postgres) GetUser(username, password, clientid string) bool {
return false
}
if common.HashCompare(password, pwHash.String, o.SaltEncoding) {
if o.hasher.Compare(password, pwHash.String) {
return true
}
@ -228,7 +218,7 @@ func (o Postgres) CheckAcl(username, topic, clientid string, acc int32) bool {
for _, acl := range acls {
aclTopic := strings.Replace(acl, "%c", clientid, -1)
aclTopic = strings.Replace(aclTopic, "%u", username, -1)
if common.TopicsMatch(aclTopic, topic) {
if TopicsMatch(aclTopic, topic) {
return true
}
}

View File

@ -3,6 +3,7 @@ package backends
import (
"testing"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)
@ -15,7 +16,7 @@ func TestPostgres(t *testing.T) {
authOpts["pg_port"] = "5432"
Convey("If mandatory params are not set initialization should fail", t, func() {
_, err := NewPostgres(authOpts, log.DebugLevel)
_, err := NewPostgres(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "postgres"))
So(err, ShouldBeError)
})
@ -28,7 +29,7 @@ func TestPostgres(t *testing.T) {
authOpts["pg_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = $1 AND test_acl.test_user_id = test_user.id AND (rw = $2 or rw = 3)"
Convey("Given valid params NewPostgres should return a Postgres backend instance", t, func() {
postgres, err := NewPostgres(authOpts, log.DebugLevel)
postgres, err := NewPostgres(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "postgres"))
So(err, ShouldBeNil)
//Empty db

View File

@ -9,7 +9,7 @@ import (
"time"
goredis "github.com/go-redis/redis/v8"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
)
@ -44,9 +44,10 @@ type Redis struct {
conn RedisClient
disableSuperuser bool
ctx context.Context
hasher hashing.HashComparer
}
func NewRedis(authOpts map[string]string, logLevel log.Level) (Redis, error) {
func NewRedis(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Redis, error) {
log.SetLevel(logLevel)
@ -56,6 +57,7 @@ func NewRedis(authOpts map[string]string, logLevel log.Level) (Redis, error) {
DB: 1,
SaltEncoding: "base64",
ctx: context.Background(),
hasher: hasher,
}
if authOpts["redis_disable_superuser"] == "true" {
@ -74,16 +76,6 @@ func NewRedis(authOpts map[string]string, logLevel log.Level) (Redis, error) {
redis.Password = redisPassword
}
if saltEncoding, ok := authOpts["redis_salt_encoding"]; ok {
switch saltEncoding {
case common.Base64, common.UTF8:
redis.SaltEncoding = saltEncoding
log.Debugf("redis backend: set salt encoding to: %s", saltEncoding)
default:
log.Errorf("redis backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding)
}
}
if redisDB, ok := authOpts["redis_db"]; ok {
db, err := strconv.ParseInt(redisDB, 10, 32)
if err == nil {
@ -175,7 +167,7 @@ func (o Redis) getUser(username, password string) (bool, error) {
return false, err
}
if common.HashCompare(password, pwHash, o.SaltEncoding) {
if o.hasher.Compare(password, pwHash) {
return true, nil
}
@ -330,7 +322,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 common.TopicsMatch(acl, topic) {
if TopicsMatch(acl, topic) {
return true, nil
}
}
@ -338,7 +330,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 common.TopicsMatch(aclTopic, topic) {
if TopicsMatch(aclTopic, topic) {
return true, nil
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
@ -35,7 +36,7 @@ func TestRedisCluster(t *testing.T) {
}
func testRedis(ctx context.Context, t *testing.T, authOpts map[string]string) {
redis, err := NewRedis(authOpts, log.DebugLevel)
redis, err := NewRedis(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "redis"))
assert.Nil(t, err)
//Empty db

View File

@ -4,7 +4,7 @@ import (
"database/sql"
"strings"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/pkg/errors"
@ -18,10 +18,10 @@ type Sqlite struct {
UserQuery string
SuperuserQuery string
AclQuery string
SaltEncoding string
hasher hashing.HashComparer
}
func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) {
func NewSqlite(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Sqlite, error) {
log.SetLevel(logLevel)
@ -33,7 +33,7 @@ func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) {
var sqlite = Sqlite{
SuperuserQuery: "",
AclQuery: "",
SaltEncoding: "base64",
hasher: hasher,
}
if source, ok := authOpts["sqlite_source"]; ok {
@ -58,16 +58,6 @@ func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) {
sqlite.AclQuery = aclQuery
}
if saltEncoding, ok := authOpts["sqlite_salt_encoding"]; ok {
switch saltEncoding {
case common.Base64, common.UTF8:
sqlite.SaltEncoding = saltEncoding
log.Debugf("sqlite backend: set salt encoding to: %s", saltEncoding)
default:
log.Errorf("sqlite backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding)
}
}
//Exit if any mandatory option is missing.
if !sqliteOk {
return sqlite, errors.Errorf("sqlite backend error: missing options: %s", missingOptions)
@ -80,7 +70,7 @@ func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) {
}
var err error
sqlite.DB, err = common.OpenDatabase(connStr, "sqlite3")
sqlite.DB, err = OpenDatabase(connStr, "sqlite3")
if err != nil {
return sqlite, errors.Errorf("sqlite backend error: couldn't open db %s: %s", connStr, err)
@ -106,7 +96,7 @@ func (o Sqlite) GetUser(username, password, clientid string) bool {
return false
}
if common.HashCompare(password, pwHash.String, o.SaltEncoding) {
if o.hasher.Compare(password, pwHash.String) {
return true
}
@ -162,7 +152,7 @@ func (o Sqlite) CheckAcl(username, topic, clientid string, acc int32) bool {
for _, acl := range acls {
aclTopic := strings.Replace(acl, "%c", clientid, -1)
aclTopic = strings.Replace(aclTopic, "%u", username, -1)
if common.TopicsMatch(aclTopic, topic) {
if TopicsMatch(aclTopic, topic) {
return true
}
}

View File

@ -4,8 +4,8 @@ import (
"os"
"testing"
"github.com/iegomez/mosquitto-go-auth/hashing"
log "github.com/sirupsen/logrus"
. "github.com/smartystreets/goconvey/convey"
)
@ -35,7 +35,7 @@ func TestFileSqlite(t *testing.T) {
authOpts := make(map[string]string)
Convey("If mandatory params are not set initialization should fail", t, func() {
_, err := NewSqlite(authOpts, log.DebugLevel)
_, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite"))
So(err, ShouldBeError)
})
@ -56,7 +56,7 @@ func TestFileSqlite(t *testing.T) {
authOpts["sqlite_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = ? AND test_acl.test_user_id = test_user.id AND rw >= ?"
Convey("Given valid params NewSqlite should return a Sqlite backend instance", t, func() {
sqlite, err := NewSqlite(authOpts, log.DebugLevel)
sqlite, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite"))
So(err, ShouldBeNil)
//Create schemas
@ -213,7 +213,7 @@ func TestMemorySqlite(t *testing.T) {
authOpts := make(map[string]string)
Convey("If mandatory params are not set initialization should fail", t, func() {
_, err := NewSqlite(authOpts, log.DebugLevel)
_, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite"))
So(err, ShouldBeError)
})
@ -224,7 +224,7 @@ func TestMemorySqlite(t *testing.T) {
authOpts["sqlite_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = ? AND test_acl.test_user_id = test_user.id AND rw >= ?"
Convey("Given valid params NewSqlite should return a Sqlite backend instance", t, func() {
sqlite, err := NewSqlite(authOpts, log.DebugLevel)
sqlite, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite"))
So(err, ShouldBeNil)
//Create schemas

22
backends/topic.go Normal file
View File

@ -0,0 +1,22 @@
package backends
import "strings"
func TopicsMatch(savedTopic, givenTopic string) bool {
return givenTopic == savedTopic || match(strings.Split(savedTopic, "/"), strings.Split(givenTopic, "/"))
}
func match(route []string, topic []string) bool {
switch {
case len(route) == 0:
return len(topic) == 0
case len(topic) == 0:
return route[0] == "#"
case route[0] == "#":
return true
case route[0] == "+", route[0] == topic[0]:
return match(route[1:], topic[1:])
}
return false
}

35
cache/cache.go vendored
View File

@ -2,8 +2,10 @@ package cache
import (
"context"
"crypto/sha1"
b64 "encoding/base64"
"fmt"
"hash"
"strings"
"time"
@ -18,12 +20,14 @@ type redisStore struct {
authExpiration time.Duration
aclExpiration time.Duration
client bes.RedisClient
h hash.Hash
}
type goStore struct {
authExpiration time.Duration
aclExpiration time.Duration
client *goCache.Cache
h hash.Hash
}
const (
@ -47,6 +51,7 @@ func NewGoStore(authExpiration, aclExpiration time.Duration) *goStore {
authExpiration: authExpiration,
aclExpiration: aclExpiration,
client: goCache.New(time.Second*defaultExpiration, time.Second*(defaultExpiration*2)),
h: sha1.New(),
}
}
@ -63,6 +68,7 @@ func NewSingleRedisStore(host, port, password string, db int, authExpiration, ac
authExpiration: authExpiration,
aclExpiration: aclExpiration,
client: bes.SingleRedisClient{redisClient},
h: sha1.New(),
}
}
@ -78,15 +84,20 @@ func NewRedisClusterStore(password string, addresses []string, authExpiration, a
authExpiration: authExpiration,
aclExpiration: aclExpiration,
client: clusterClient,
h: sha1.New(),
}
}
func toAuthRecord(username, password string) string {
return b64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("auth-%s-%s", username, password)))
func toAuthRecord(username, password string, h hash.Hash) string {
sum := h.Sum([]byte(fmt.Sprintf("auth-%s-%s", username, password)))
log.Debugf("to auth record: %v\n", sum)
return b64.StdEncoding.EncodeToString(sum)
}
func toACLRecord(username, topic, clientid string, acc int) string {
return b64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("acl-%s-%s-%s-%d", username, topic, clientid, acc)))
func toACLRecord(username, topic, clientid string, acc int, h hash.Hash) string {
sum := h.Sum([]byte(fmt.Sprintf("acl-%s-%s-%s-%d", username, topic, clientid, acc)))
log.Debugf("to auth record: %v\n", sum)
return b64.StdEncoding.EncodeToString(sum)
}
// Checks if an error was caused by a moved record in a Redis Cluster.
@ -136,13 +147,13 @@ func (s *redisStore) Close() {
// CheckAuthRecord checks if the username/password pair is present in the cache. Return if it's present and, if so, if it was granted privileges
func (s *goStore) CheckAuthRecord(ctx context.Context, username, password string) (bool, bool) {
record := toAuthRecord(username, password)
record := toAuthRecord(username, password, s.h)
return s.checkRecord(ctx, record, s.authExpiration)
}
//CheckAclCache checks if the username/topic/clientid/acc mix is present in the cache. Return if it's present and, if so, if it was granted privileges.
func (s *goStore) CheckACLRecord(ctx context.Context, username, topic, clientid string, acc int) (bool, bool) {
record := toACLRecord(username, topic, clientid, acc)
record := toACLRecord(username, topic, clientid, acc, s.h)
return s.checkRecord(ctx, record, s.aclExpiration)
}
@ -163,13 +174,13 @@ func (s *goStore) checkRecord(ctx context.Context, record string, expirationTime
// CheckAuthRecord checks if the username/password pair is present in the cache. Return if it's present and, if so, if it was granted privileges
func (s *redisStore) CheckAuthRecord(ctx context.Context, username, password string) (bool, bool) {
record := toAuthRecord(username, password)
record := toAuthRecord(username, password, s.h)
return s.checkRecord(ctx, record, s.authExpiration)
}
//CheckAclCache checks if the username/topic/clientid/acc mix is present in the cache. Return if it's present and, if so, if it was granted privileges.
func (s *redisStore) CheckACLRecord(ctx context.Context, username, topic, clientid string, acc int) (bool, bool) {
record := toACLRecord(username, topic, clientid, acc)
record := toACLRecord(username, topic, clientid, acc, s.h)
return s.checkRecord(ctx, record, s.aclExpiration)
}
@ -219,7 +230,7 @@ func (s *redisStore) getAndRefresh(ctx context.Context, record string, expiratio
// SetAuthRecord sets a pair, granted option and expiration time.
func (s *goStore) SetAuthRecord(ctx context.Context, username, password string, granted string) error {
record := toAuthRecord(username, password)
record := toAuthRecord(username, password, s.h)
s.client.Set(record, granted, time.Second*time.Duration(s.authExpiration))
return nil
@ -227,7 +238,7 @@ func (s *goStore) SetAuthRecord(ctx context.Context, username, password string,
//SetAclCache sets a mix, granted option and expiration time.
func (s *goStore) SetACLRecord(ctx context.Context, username, topic, clientid string, acc int, granted string) error {
record := toACLRecord(username, topic, clientid, acc)
record := toACLRecord(username, topic, clientid, acc, s.h)
s.client.Set(record, granted, time.Second*time.Duration(s.authExpiration))
return nil
@ -235,13 +246,13 @@ func (s *goStore) SetACLRecord(ctx context.Context, username, topic, clientid st
// SetAuthRecord sets a pair, granted option and expiration time.
func (s *redisStore) SetAuthRecord(ctx context.Context, username, password string, granted string) error {
record := toAuthRecord(username, password)
record := toAuthRecord(username, password, s.h)
return s.setRecord(ctx, record, granted, s.authExpiration)
}
//SetAclCache sets a mix, granted option and expiration time.
func (s *redisStore) SetACLRecord(ctx context.Context, username, topic, clientid string, acc int, granted string) error {
record := toACLRecord(username, topic, clientid, acc)
record := toACLRecord(username, topic, clientid, acc, s.h)
return s.setRecord(ctx, record, granted, s.authExpiration)
}

View File

@ -1,186 +0,0 @@
package common
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/pkg/errors"
"golang.org/x/crypto/pbkdf2"
"github.com/jmoiron/sqlx"
)
// Declare the valid encodings for validation.
const (
UTF8 = "utf-8"
Base64 = "base64"
)
// OpenDatabase opens the database and performs a ping to make sure the
// database is up.
// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server
func OpenDatabase(dsn, engine string) (*sqlx.DB, error) {
db, err := sqlx.Open(engine, dsn)
if err != nil {
return nil, errors.Wrap(err, "database connection error")
}
for {
if err = db.Ping(); err != nil {
log.Errorf("ping database error, will retry in 2s: %s", err)
time.Sleep(2 * time.Second)
} else {
break
}
}
return db, nil
}
func TopicsMatch(savedTopic, givenTopic string) bool {
return givenTopic == savedTopic || match(strings.Split(savedTopic, "/"), strings.Split(givenTopic, "/"))
}
func match(route []string, topic []string) bool {
if len(route) == 0 {
if len(topic) == 0 {
return true
}
return false
}
if len(topic) == 0 {
if route[0] == "#" {
return true
}
return false
}
if route[0] == "#" {
return true
}
if (route[0] == "+") || (route[0] == topic[0]) {
return match(route[1:], topic[1:])
}
return false
}
/*
* PBKDF2 passwords usage taken from github.com/brocaar/lora-app-server, comments included.
*/
// Generate the hash of a password for storage in the database.
// NOTE: We store the details of the hashing algorithm with the hash itself,
// making it easy to recreate the hash for password checking, even if we change
// the default criteria here.
// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server
func Hash(password string, saltSize int, iterations int, algorithm string, saltEncoding string, keylen int) (string, error) {
// Generate a random salt value, 128 bits.
salt := make([]byte, saltSize)
_, err := rand.Read(salt)
if err != nil {
return "", errors.Wrap(err, "read random bytes error")
}
return hashWithSalt(password, salt, iterations, algorithm, saltEncoding, keylen), nil
}
// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server
func hashWithSalt(password string, salt []byte, iterations int, algorithm string, saltEncoding string, keylen int) string {
// Generate the hash. This should be a little painful, adjust ITERATIONS
// if it needs performance tweeking. Greatly depends on the hardware.
// NOTE: We store these details with the returned hash, so changes will not
// affect our ability to do password compares.
shaHash := sha512.New
if algorithm == "sha256" {
shaHash = sha256.New
}
hash := pbkdf2.Key([]byte(password), salt, iterations, keylen, shaHash)
// Build up the parameters and hash into a single string so we can compare
// other string to the same hash. Note that the hash algorithm is hard-
// coded here, as it is above. Introducing alternate encodings must support
// old encodings as well, and build this string appropriately.
var buffer bytes.Buffer
buffer.WriteString("PBKDF2$")
buffer.WriteString(fmt.Sprintf("%s$", algorithm))
buffer.WriteString(strconv.Itoa(iterations))
buffer.WriteString("$")
// Re-encode salt, using encoding supplied in saltEncoding param
switch saltEncoding {
case UTF8:
buffer.WriteString(string(salt))
case Base64:
buffer.WriteString(base64.StdEncoding.EncodeToString(salt))
default:
log.Errorf("Supplied saltEncoding not supported: %s, defaulting to base64", saltEncoding)
buffer.WriteString(base64.StdEncoding.EncodeToString(salt))
}
buffer.WriteString("$")
buffer.WriteString(base64.StdEncoding.EncodeToString(hash))
//log.Debugf("Generated: ", buffer.String())
return buffer.String()
}
// HashCompare verifies that passed password hashes to the same value as the
// passed passwordHash.
// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server
func HashCompare(password string, passwordHash string, saltEncoding string) bool {
// Split the hash string into its parts.
hashSplit := strings.Split(passwordHash, "$")
// Check array is of expected length
if len(hashSplit) != 5 {
log.Errorf("HashCompare, invalid PBKDF2 hash supplied.")
return false
}
// Get the iterations from PBKDF2 string
iterations, err := strconv.Atoi(hashSplit[2])
if err != nil {
log.Errorf("Error getting number of iterations from PBKDF2 hash.")
return false
}
// Convert salt to bytes, using encoding supplied in saltEncoding param
salt := []byte{}
switch saltEncoding {
case UTF8:
salt = []byte(hashSplit[3])
case Base64:
salt, err = base64.StdEncoding.DecodeString(hashSplit[3])
if err != nil {
log.Errorf("Error decoding supplied base64 salt.")
return false
}
default:
log.Errorf("Supplied saltEncoding not supported: %s, defaulting to base64", saltEncoding)
salt, err = base64.StdEncoding.DecodeString(hashSplit[3])
if err != nil {
log.Errorf("Error decoding supplied base64 salt.")
return false
}
}
// Work out key length, assumes base64 encoding
hash, err := base64.StdEncoding.DecodeString(hashSplit[4])
if err != nil {
log.Errorf("Error decoding supplied base64 hash.")
return false
}
keylen := len(hash)
// Get the algorithm from PBKDF2 string
algorithm := hashSplit[1]
// Generate new PBKDF2 hash to compare against supplied PBKDF2 string
newHash := hashWithSalt(password, salt, iterations, algorithm, saltEncoding, keylen)
return newHash == passwordHash
}

View File

@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/iegomez/mosquitto-go-auth/hashing"
bes "github.com/iegomez/mosquitto-go-auth/backends"
"github.com/iegomez/mosquitto-go-auth/cache"
log "github.com/sirupsen/logrus"
@ -41,6 +43,7 @@ type AuthPlugin struct {
disableSuperuser bool
ctx context.Context
cache cache.Store
hasher hashing.HashComparer
}
const (
@ -83,7 +86,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends := make(map[string]Backend)
//Initialize common struct with default and given values
//Initialize auth plugin struct with default and given values.
authPlugin = AuthPlugin{
checkPrefix: false,
prefixes: make(map[string]string),
@ -251,9 +254,10 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
}
} else {
hasher := hashing.NewHasher(authOpts, bename)
switch bename {
case postgresBackend:
beIface, err = bes.NewPostgres(authOpts, authPlugin.logLevel)
beIface, err = bes.NewPostgres(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
@ -261,7 +265,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends[postgresBackend] = beIface.(bes.Postgres)
}
case jwtBackend:
beIface, err = bes.NewJWT(authOpts, authPlugin.logLevel)
beIface, err = bes.NewJWT(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
@ -269,7 +273,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends[jwtBackend] = beIface.(bes.JWT)
}
case filesBackend:
beIface, err = bes.NewFiles(authOpts, authPlugin.logLevel)
beIface, err = bes.NewFiles(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
@ -277,7 +281,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends[filesBackend] = beIface.(bes.Files)
}
case redisBackend:
beIface, err = bes.NewRedis(authOpts, authPlugin.logLevel)
beIface, err = bes.NewRedis(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
@ -285,7 +289,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends[redisBackend] = beIface.(bes.Redis)
}
case mysqlBackend:
beIface, err = bes.NewMysql(authOpts, authPlugin.logLevel)
beIface, err = bes.NewMysql(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
@ -301,7 +305,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends[httpBackend] = beIface.(bes.HTTP)
}
case sqliteBackend:
beIface, err = bes.NewSqlite(authOpts, authPlugin.logLevel)
beIface, err = bes.NewSqlite(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {
@ -309,7 +313,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) {
cmBackends[sqliteBackend] = beIface.(bes.Sqlite)
}
case mongoBackend:
beIface, err = bes.NewMongo(authOpts, authPlugin.logLevel)
beIface, err = bes.NewMongo(authOpts, authPlugin.logLevel, hasher)
if err != nil {
log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err)
} else {

107
hashing/argon2id.go Normal file
View File

@ -0,0 +1,107 @@
package hashing
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/argon2"
)
type argon2IDHasher struct {
saltSize int
iterations int
keyLen int
memory uint32
parallelism uint8
}
func NewArgon2IDHasher(saltSize int, iterations int, keylen int, memory uint32, parallelism uint8) HashComparer {
return argon2IDHasher{
saltSize: saltSize,
iterations: iterations,
keyLen: keylen,
memory: memory,
parallelism: parallelism,
}
}
// Hash generates a hashed password using Argon2ID.
func (h argon2IDHasher) Hash(password string) (string, error) {
salt := make([]byte, h.saltSize)
_, err := rand.Read(salt)
if err != nil {
return "", errors.Wrap(err, "read random bytes error")
}
return h.hashWithSalt(password, salt, h.memory, h.iterations, h.parallelism, h.keyLen), nil
}
// Compare checks that an argon2 generated password matches the password hash.
func (h argon2IDHasher) Compare(password string, passwordHash string) bool {
hashSplit := strings.Split(passwordHash, "$")
if hashSplit[1] != "argon2id" {
log.Errorf("unknown hash format: %s", hashSplit[1])
}
if len(hashSplit) != 6 {
log.Errorf("invalid hash supplied, expected 6 elements, got: %d", len(hashSplit))
return false
}
version, err := strconv.ParseInt(strings.TrimPrefix(hashSplit[2], "v="), 10, 32)
if err != nil {
log.Errorf("argon2id version parse error: %s", err)
return false
}
if version != argon2.Version {
log.Errorf("unknown argon2id version: %d", version)
return false
}
var memory, iterations uint32
var parallelism uint8
_, err = fmt.Sscanf(hashSplit[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism)
if err != nil {
log.Errorf("argon2id parameters parse error: %s", err)
return false
}
salt, err := base64.RawStdEncoding.DecodeString(hashSplit[4])
if err != nil {
log.Errorf("base64 salt error: %s", err)
return false
}
extractedHash, err := base64.RawStdEncoding.DecodeString(hashSplit[5])
if err != nil {
log.Errorf("argon2id decoding error: %s", err)
return false
}
keylen := uint32(len(extractedHash))
newHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keylen)
if subtle.ConstantTimeCompare(newHash, extractedHash) == 1 {
return true
}
return false
}
func (h argon2IDHasher) hashWithSalt(password string, salt []byte, memory uint32, iterations int, parallelism uint8, keylen int) string {
hashedPassword := argon2.IDKey([]byte(password), salt, uint32(iterations), memory, parallelism, uint32(keylen))
b64salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hashedPassword)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.memory, h.iterations, h.parallelism, b64salt, b64Hash)
}

30
hashing/bcrypt.go Normal file
View File

@ -0,0 +1,30 @@
package hashing
import (
"golang.org/x/crypto/bcrypt"
)
type bcryptHasher struct {
cost int
}
func NewBcryptHashComparer(cost int) HashComparer {
return bcryptHasher{
cost: cost,
}
}
// Hash generates a hashed password using bcrypt.
func (h bcryptHasher) Hash(password string) (string, error) {
generated, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
return string(generated), err
}
// Compare checks that a bcrypt generated password matches the password hash.
func (h bcryptHasher) Compare(password, passwordHash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
if err != nil {
return false
}
return true
}

150
hashing/hashing.go Normal file
View File

@ -0,0 +1,150 @@
package hashing
import (
"fmt"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
const (
// algorithms
SHA512 = "sha512"
SHA256 = "sha256"
SHA256Size = 32
SHA512Size = 64
// encodings
UTF8 = "utf-8"
Base64 = "base64"
// hashers
Pbkdf2Opt = "pbkdf2"
Argon2IDOpt = "argon2ID"
BcryptOpt = "bcrypt"
// defaults
defaultBcryptCost = 10
defaultArgon2IDSaltSize = 16
defaultArgon2IDMemory uint32 = 4096
defaultArgon2IDIterations = 3
defaultArgon2IDParallelism uint8 = 2
defaultArgon2IDKeyLen = 32
defaultPBKDF2SaltSize = 16
defaultPBKDF2Iterations = 100000
defaultPBKDF2KeyLen = 32
defaultPBKDF2Algorithm = SHA512
)
var saltEncodings = map[string]struct{}{
UTF8: {},
Base64: {},
}
type HashComparer interface {
Hash(password string) (string, error)
Compare(password, passwordHash string) bool
}
func preferredEncoding(saltEncoding string) string {
preferredEncoding := Base64
if _, ok := saltEncodings[saltEncoding]; ok {
preferredEncoding = saltEncoding
}
return preferredEncoding
}
// Process hash opts:
// Empty backend: use whatever plugin wise hashing options are present by returning whole opts.
// Backend present: check if there's a backend_hasher option:
// - Yes: return a new map with whatever hashing options are present for the given backend and hasher
// (defaults will be used for missing options).
// - No: use whatever plugin wise hashing options are present by returning whole opts.
func processHashOpts(authOpts map[string]string, backend string) map[string]string {
// Return authOpts if no backend given.
if backend == "" {
return authOpts
}
// Return authOpts if no hasher was passed for the backend.
if _, ok := authOpts[fmt.Sprintf("%s_hasher", backend)]; !ok {
return authOpts
}
// Extract specific backend options.
hashOpts := make(map[string]string)
for k, v := range authOpts {
if strings.Contains(k, backend) {
hashOpts[strings.TrimPrefix(k, backend+"_")] = v
}
}
return hashOpts
}
// NewHasher returns a hasher depending on the given options.
func NewHasher(authOpts map[string]string, backend string) HashComparer {
opts := processHashOpts(authOpts, backend)
switch opts["hasher"] {
case BcryptOpt:
log.Debugf("new hasher: %s", BcryptOpt)
cost, err := strconv.ParseInt(opts["hasher_cost"], 10, 64)
if err != nil {
return NewBcryptHashComparer(defaultBcryptCost)
}
return NewBcryptHashComparer(int(cost))
case Argon2IDOpt:
log.Debugf("new hasher: %s", Argon2IDOpt)
saltSize := defaultArgon2IDSaltSize
if v, err := strconv.ParseInt(opts["hasher_salt_size"], 10, 64); err == nil {
saltSize = int(v)
}
memory := defaultArgon2IDMemory
if v, err := strconv.ParseUint(opts["hasher_memory"], 10, 32); err == nil {
memory = uint32(v)
}
iterations := defaultArgon2IDIterations
if v, err := strconv.ParseInt(opts["hasher_iterations"], 10, 64); err == nil {
iterations = int(v)
}
parallelism := defaultArgon2IDParallelism
if v, err := strconv.ParseUint(opts["hasher_parallelism"], 10, 8); err == nil {
parallelism = uint8(v)
}
keyLen := defaultArgon2IDKeyLen
if v, err := strconv.ParseInt(opts["hasher_keylen"], 10, 64); err == nil {
keyLen = int(v)
}
return NewArgon2IDHasher(saltSize, iterations, keyLen, memory, parallelism)
case Pbkdf2Opt:
log.Debugf("new hasher: %s", Pbkdf2Opt)
default:
log.Warnln("unknown or empty hasher, defaulting to PBKDF2")
}
saltSize := defaultPBKDF2SaltSize
if v, err := strconv.ParseInt(opts["hasher_salt_size"], 10, 64); err == nil {
saltSize = int(v)
}
iterations := defaultPBKDF2Iterations
if v, err := strconv.ParseInt(opts["hasher_iterations"], 10, 64); err == nil {
iterations = int(v)
}
keyLen := defaultPBKDF2KeyLen
if v, err := strconv.ParseInt(opts["hasher_keylen"], 10, 64); err == nil {
keyLen = int(v)
}
algorithm := defaultPBKDF2Algorithm
if opts["hasher_algorithm"] == "sha256" {
algorithm = SHA256
}
saltEncoding := opts["hasher_salt_encoding"]
return NewPBKDF2Hasher(saltSize, iterations, algorithm, saltEncoding, keyLen)
return nil
}

147
hashing/hashing_test.go Normal file
View File

@ -0,0 +1,147 @@
package hashing
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewHasher(t *testing.T) {
authOpts := make(map[string]string)
hasher := NewHasher(authOpts, "")
_, ok := hasher.(pbkdf2Hasher)
assert.True(t, ok)
authOpts = make(map[string]string)
authOpts["hasher"] = Pbkdf2Opt
hasher = NewHasher(authOpts, "")
pHasher, ok := hasher.(pbkdf2Hasher)
assert.True(t, ok)
assert.Equal(t, defaultPBKDF2Algorithm, pHasher.algorithm)
assert.Equal(t, defaultPBKDF2KeyLen, pHasher.keyLen)
assert.Equal(t, defaultPBKDF2Iterations, pHasher.iterations)
assert.Equal(t, defaultPBKDF2SaltSize, pHasher.saltSize)
assert.Equal(t, Base64, pHasher.saltEncoding)
// Check that options are set correctly.
authOpts = make(map[string]string)
authOpts = map[string]string{
"hasher": Pbkdf2Opt,
"hasher_algorithm": SHA256,
"hasher_keylen": "24",
"hasher_iterations": "100",
"hasher_salt_size": "30",
"hasher_salt_encoding": UTF8,
}
hasher = NewHasher(authOpts, "")
pHasher, ok = hasher.(pbkdf2Hasher)
assert.True(t, ok)
assert.Equal(t, SHA256, pHasher.algorithm)
assert.Equal(t, 24, pHasher.keyLen)
assert.Equal(t, 100, pHasher.iterations)
assert.Equal(t, 30, pHasher.saltSize)
assert.Equal(t, UTF8, pHasher.saltEncoding)
authOpts = make(map[string]string)
authOpts["hasher"] = Argon2IDOpt
hasher = NewHasher(authOpts, "")
aHasher, ok := hasher.(argon2IDHasher)
assert.True(t, ok)
assert.Equal(t, defaultArgon2IDIterations, aHasher.iterations)
assert.Equal(t, defaultArgon2IDKeyLen, aHasher.keyLen)
assert.Equal(t, defaultArgon2IDMemory, aHasher.memory)
assert.Equal(t, defaultArgon2IDParallelism, aHasher.parallelism)
assert.Equal(t, defaultArgon2IDSaltSize, aHasher.saltSize)
authOpts = make(map[string]string)
authOpts = map[string]string{
"hasher": Argon2IDOpt,
"hasher_iterations": "100",
"hasher_keylen": "24",
"hasher_memory": "1024",
"hasher_parallelism": "4",
"hasher_salt_size": "24",
}
hasher = NewHasher(authOpts, "")
aHasher, ok = hasher.(argon2IDHasher)
assert.True(t, ok)
assert.Equal(t, 100, aHasher.iterations)
assert.Equal(t, 24, aHasher.keyLen)
assert.Equal(t, uint32(1024), aHasher.memory)
assert.Equal(t, uint8(4), aHasher.parallelism)
assert.Equal(t, 24, aHasher.saltSize)
authOpts = make(map[string]string)
authOpts["hasher"] = BcryptOpt
hasher = NewHasher(authOpts, "")
bHasher, ok := hasher.(bcryptHasher)
assert.True(t, ok)
assert.Equal(t, bHasher.cost, defaultBcryptCost)
// Check that options are set correctly.
authOpts = make(map[string]string)
authOpts = map[string]string{
"hasher": BcryptOpt,
"hasher_cost": "15",
}
hasher = NewHasher(authOpts, "")
bHasher, ok = hasher.(bcryptHasher)
assert.True(t, ok)
assert.Equal(t, 15, bHasher.cost)
}
func TestBcrypt(t *testing.T) {
password := "test-password"
hasher := NewBcryptHashComparer(10)
passwordHash, err := hasher.Hash(password)
assert.Nil(t, err)
assert.True(t, hasher.Compare(password, passwordHash))
assert.False(t, hasher.Compare("other", passwordHash))
}
func TestArgon2ID(t *testing.T) {
password := "test-password"
hasher := NewArgon2IDHasher(defaultArgon2IDSaltSize, defaultArgon2IDIterations, defaultArgon2IDKeyLen, defaultArgon2IDMemory, defaultArgon2IDParallelism)
passwordHash, err := hasher.Hash(password)
assert.Nil(t, err)
assert.True(t, hasher.Compare(password, passwordHash))
assert.False(t, hasher.Compare("other", passwordHash))
}
func TestPBKDF2(t *testing.T) {
password := "test-password"
// Test base64.
hasher := NewPBKDF2Hasher(defaultPBKDF2SaltSize, defaultPBKDF2Iterations, defaultPBKDF2Algorithm, Base64, defaultPBKDF2KeyLen)
passwordHash, err := hasher.Hash(password)
assert.Nil(t, err)
assert.True(t, hasher.Compare(password, passwordHash))
assert.False(t, hasher.Compare("other", passwordHash))
// Test UTF8.
hasher = NewPBKDF2Hasher(defaultPBKDF2SaltSize, defaultPBKDF2Iterations, defaultPBKDF2Algorithm, UTF8, defaultPBKDF2KeyLen)
passwordHash, err = hasher.Hash(password)
assert.Nil(t, err)
assert.True(t, hasher.Compare(password, passwordHash))
assert.False(t, hasher.Compare("other", passwordHash))
}

145
hashing/pbkdf2.go Normal file
View File

@ -0,0 +1,145 @@
package hashing
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"math/big"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/pbkdf2"
)
type pbkdf2Hasher struct {
saltSize int
iterations int
algorithm string
saltEncoding string
keyLen int
}
func NewPBKDF2Hasher(saltSize int, iterations int, algorithm string, saltEncoding string, keylen int) HashComparer {
return pbkdf2Hasher{
saltSize: saltSize,
iterations: iterations,
algorithm: algorithm,
saltEncoding: preferredEncoding(saltEncoding),
keyLen: keylen,
}
}
/*
* PBKDF2 methods are adapted from github.com/brocaar/chirpstack-application-server, some comments included.
*/
// Hash function reference may be found at https://github.com/brocaar/chirpstack-application-server/blob/master/internal/storage/user.go#L421.
// Generate the hash of a password for storage in the database.
// NOTE: We store the details of the hashing algorithm with the hash itself,
// making it easy to recreate the hash for password checking, even if we change
// the default criteria here.
func (h pbkdf2Hasher) Hash(password string) (string, error) {
// Generate a random salt value with the given salt size.
salt := make([]byte, h.saltSize)
_, err := rand.Read(salt)
// We need to ensure that salt doesn contain $, which is 36 in decimal.
// So we check if there'sbyte that represents $ and change it with a random number in the range 0-35
//// This is far from ideal, but should be good enough with a reasonable salt size.
for i := 0; i < len(salt); i++ {
if salt[i] == 36 {
n, err := rand.Int(rand.Reader, big.NewInt(35))
if err != nil {
return "", fmt.Errorf("read random byte error: %s", err)
}
salt[i] = byte(n.Int64())
break
}
}
if err != nil {
return "", fmt.Errorf("read random bytes error: %s", err)
}
return h.hashWithSalt(password, salt, h.iterations, h.algorithm, h.keyLen), nil
}
// HashCompare verifies that passed password hashes to the same value as the
// passed passwordHash.
// Reference: https://github.com/brocaar/chirpstack-application-server/blob/master/internal/storage/user.go#L458.
func (h pbkdf2Hasher) Compare(password string, passwordHash string) bool {
hashSplit := strings.Split(passwordHash, "$")
if len(hashSplit) != 5 {
log.Errorf("invalid PBKDF2 hash supplied, expected length 5, got: %d", len(hashSplit))
return false
}
algorithm := hashSplit[1]
iterations, err := strconv.Atoi(hashSplit[2])
if err != nil {
log.Errorf("iterations error: %s", err)
return false
}
var salt []byte
switch h.saltEncoding {
case UTF8:
salt = []byte(hashSplit[3])
default:
salt, err = base64.StdEncoding.DecodeString(hashSplit[3])
if err != nil {
log.Errorf("base64 salt error: %s", err)
return false
}
}
hashedPassword, err := base64.StdEncoding.DecodeString(hashSplit[4])
if err != nil {
log.Errorf("base64 hash decoding error: %s", err)
return false
}
keylen := len(hashedPassword)
return passwordHash == h.hashWithSalt(password, salt, iterations, algorithm, keylen)
}
// Reference: https://github.com/brocaar/chirpstack-application-server/blob/master/internal/storage/user.go#L432.
func (h pbkdf2Hasher) hashWithSalt(password string, salt []byte, iterations int, algorithm string, keylen int) string {
// Generate the hashed password. This should be a little painful, adjust ITERATIONS
// if it needs performance tweeking. Greatly depends on the hardware.
// NOTE: We store these details with the returned hashed, so changes will not
// affect our ability to do password compares.
shaHash := sha512.New
if algorithm == SHA256 {
shaHash = sha256.New
}
hashed := pbkdf2.Key([]byte(password), salt, iterations, keylen, shaHash)
var buffer bytes.Buffer
buffer.WriteString("PBKDF2$")
buffer.WriteString(fmt.Sprintf("%s$", algorithm))
buffer.WriteString(strconv.Itoa(iterations))
buffer.WriteString("$")
switch h.saltEncoding {
case UTF8:
buffer.WriteString(string(salt))
default:
buffer.WriteString(base64.StdEncoding.EncodeToString(salt))
}
buffer.WriteString("$")
buffer.WriteString(base64.StdEncoding.EncodeToString(hashed))
return buffer.String()
}

View File

@ -4,40 +4,53 @@ import (
"flag"
"fmt"
"github.com/iegomez/mosquitto-go-auth/common"
"github.com/iegomez/mosquitto-go-auth/hashing"
)
func main() {
const (
sha256Size = 32
sha512Size = 64
)
var hasher = flag.String("h", "pbkdf2", "hasher: pbkdf2, argon2 or bcrypt")
var algorithm = flag.String("a", "sha512", "algorithm: sha256 or sha512")
var HashIterations = flag.Int("i", 100000, "hash iterations")
var iterations = flag.Int("i", 100000, "hash iterations: defaults to 100000 for pbkdf2, please set to a reasonable value for argon2")
var password = flag.String("p", "", "password")
var saltSize = flag.Int("s", 16, "salt size")
var saltEncoding = flag.String("e", "base64", "salt encoding")
var keylen = flag.Int("l", 0, "key length, recommend 32 for sha256 and 64 for sha512")
var keylen = flag.Int("l", 0, "key length, recommended values are 32 for sha256 and 64 for sha512")
var cost = flag.Int("c", 10, "bcrypt ost param")
var memory = flag.Int("m", 4096, "memory for argon2 hash")
var parallelism = flag.Int("pl", 2, "parallelism for argon2")
flag.Parse()
// If supplied keylength is 0, use pre-defined key length
shaSize := *keylen
if shaSize == 0 {
switch *algorithm {
case "sha265":
shaSize = sha256Size
case "sha512":
shaSize = sha512Size
case hashing.SHA256:
shaSize = hashing.SHA256Size
case hashing.SHA512:
shaSize = hashing.SHA512Size
default:
fmt.Println("Invalid password hash algorithm:", *algorithm)
fmt.Println("invalid password hash algorithm: ", *algorithm)
return
}
}
pwHash, err := common.Hash(*password, *saltSize, *HashIterations, *algorithm, *saltEncoding, shaSize)
var hashComparer hashing.HashComparer
switch *hasher {
case hashing.Argon2IDOpt:
hashComparer = hashing.NewArgon2IDHasher(*saltSize, *iterations, *keylen, uint32(*memory), uint8(*parallelism))
case hashing.BcryptOpt:
hashComparer = hashing.NewBcryptHashComparer(*cost)
case hashing.Pbkdf2Opt:
hashComparer = hashing.NewPBKDF2Hasher(*saltSize, *iterations, *algorithm, *saltEncoding, *keylen)
default:
fmt.Println("invalid hasher option: ", *hasher)
return
}
pwHash, err := hashComparer.Hash(*password)
if err != nil {
fmt.Printf("error: %s", err)
} else {