Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Http basic auth #454

Merged
merged 34 commits into from
Jan 14, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
72f2104
refactor repeated code
Jan 4, 2022
791272e
basicauth validator using userFile
Jan 5, 2022
4847899
format code
Jan 5, 2022
0d4644f
basic auth validator using etcd; cache results to LRU cache
Jan 5, 2022
30944df
fix header parsing
Jan 5, 2022
bc8f83a
reset changes in oauth2
Jan 5, 2022
9316bfa
fix test
Jan 6, 2022
5c81d42
remove unix specific temp file path
Jan 6, 2022
de2250c
sync authorized users
Jan 6, 2022
dd32290
remove syncInterval
Jan 6, 2022
60acff6
add fileWatcher for unvalidating cache
Jan 6, 2022
2b5fddb
remove unused funcs and add documentation
Jan 6, 2022
b188893
fix race condition in unittest
Jan 6, 2022
8bf353e
initialize context to fix test
Jan 6, 2022
5336a8f
simplify test and fix basic auth
Jan 7, 2022
26bd8ef
make etcd yaml password format configurable and use go-htpasswd for b…
Jan 10, 2022
03c435f
better error handling
Jan 10, 2022
0d23ac7
add test case
Jan 10, 2022
a5fd957
make etcd prefix configuratble
Jan 10, 2022
caa1a49
make username yaml entry configurable
Jan 11, 2022
6167196
set x-auth-user header
Jan 11, 2022
eeed6e9
headerlookup filter
Jan 11, 2022
4f2c339
fix typo
Jan 11, 2022
ccf9cca
fix filter
Jan 11, 2022
82796cb
sanitize header keys
Jan 12, 2022
4bbe9ae
force basicAuth and headerLookup to use /custom-data/ etcd prefix
Jan 12, 2022
bc7ff35
simplify configuration by fixing key and password entries; allow skip…
Jan 12, 2022
8cd8421
use fsnotify for basic auth userfile updates
Jan 13, 2022
68d7cf8
go mod tidy
Jan 13, 2022
b4442d6
address review comments
Jan 13, 2022
1565bab
add cache and cache update
Jan 13, 2022
7f5ba26
fix cache update
Jan 13, 2022
7b51d82
fix headerlookup test
Jan 13, 2022
249b3c7
include new cluster test helper to cluster tests
Jan 14, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
simplify configuration by fixing key and password entries; allow skip…
…ping unvalid etcd entries
  • Loading branch information
Samu Tamminen committed Jan 13, 2022
commit bc7ff35cfd0b86cafe901ad6a3845a6527c67846
2 changes: 2 additions & 0 deletions doc/cookbook/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ filters:
- kind: Validator
name: oauth-validator
basicAuth:
mode: "FILE"
userFile: '/etc/apache2/.htpasswd'
- name: proxy
kind: Proxy
Expand Down Expand Up @@ -282,6 +283,7 @@ filters:
- kind: Validator
name: basic-auth-validator
basicAuth:
mode: "FILE"
userFile: '/etc/apache2/.htpasswd'
- name: proxy
kind: Proxy
Expand Down
2 changes: 2 additions & 0 deletions doc/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ Here's an example for `basicAuth` validation method which uses [Apache2 htpasswd
kind: Validator
name: basicAuth-validator-example
basicAuth:
mode: "FILE"
userFile: /etc/apache2/.htpasswd
```

Expand All @@ -608,6 +609,7 @@ basicAuth:
| jwt | [validator.JWTValidatorSpec](#validatorJWTValidatorSpec) | JWT validation rule, validates JWT token string from the `Authorization` header or cookies | No |
| signature | [signer.Spec](#signerSpec) | Signature validation rule, implements an [Amazon Signature V4](https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html) compatible signature validation validator, with customizable literal strings | No |
| oauth2 | [validator.OAuth2ValidatorSpec](#validatorOAuth2ValidatorSpec) | The `OAuth/2` method support `Token Introspection` mode and `Self-Encoded Access Tokens` mode, only one mode can be configured at a time | No |
| basicAuth | [basicauth.BasicAuthValidatorSpec](#basicauthBasicAuthValidatorSpec) | The `BasicAuth` method support `FILE` mode and `ETCD` mode, only one mode can be configured at a time. | No |

### Results

Expand Down
109 changes: 58 additions & 51 deletions pkg/filter/validator/basicauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,29 +41,22 @@ import (
)

type (
// EtcdSpec defines etcd prefix and which yaml entries are the username and password. For example spec
// prefix: "/creds/"
// usernameKey: "user"
// passwordKey: "pw"
// expects the yaml to be stored with key /custom-data/creds/{id} in following yaml (extra keys are allowed)
// user: doge
// pw: {encrypted or plain text password}
EtcdSpec struct {
Prefix string `yaml:"prefix" jsonschema:"onitempty"`
UsernameKey string `yaml:"usernameKey" jsonschema:"omitempty"`
PasswordKey string `yaml:"passwordKey" jsonschema:"omitempty"`
}
// BasicAuthValidatorSpec defines the configuration of Basic Auth validator.
// Only one of UserFile or Etcd should be defined.
// There are 'file' and 'etcd' modes.
BasicAuthValidatorSpec struct {
Mode string `yaml:"mode" jsonschema:"omitempty,enum=FILE,enum=ETCD"`
// Required for 'FILE' mode.
// UserFile is path to file containing encrypted user credentials in apache2-utils/htpasswd format.
// To add user `userY`, use `sudo htpasswd /etc/apache2/.htpasswd userY`
// Reference: https://manpages.debian.org/testing/apache2-utils/htpasswd.1.en.html#EXAMPLES
UserFile string `yaml:"userFile" jsonschema:"omitempty"`
// When etcd is specified, verify user credentials from etcd. Etcd stores them:
// key: /custom-data/{etcd.prefix}/{username}
// value: {yaml string in format of etcd}
Etcd *EtcdSpec `yaml:"etcd" jsonschema:"omitempty"`
// Required for 'ETCD' mode.
// When EtcdPrefix is specified, verify user credentials from etcd. Etcd should store them:
// key: /custom-data/{etcdPrefix}/{$key}
// value:
// key: "$key"
// password: "$password"
EtcdPrefix string `yaml:"etcdPrefix" jsonschema:"omitempty"`
}

// AuthorizedUsersCache provides cached lookup for authorized users.
Expand All @@ -87,8 +80,6 @@ type (
userFileObject *htpasswd.File
cluster cluster.Cluster
prefix string
usernameKey string
passwordKey string
syncInterval time.Duration
stopCtx context.Context
cancel context.CancelFunc
Expand All @@ -101,7 +92,11 @@ type (
}
)

const customDataPrefix = "/custom-data/"
const (
customDataPrefix = "/custom-data/"
etcdUsernameKey = "key"
etcdPasswordKey = "password"
)

func parseCredentials(creds string) (string, string, error) {
parts := strings.Split(creds, ":")
Expand All @@ -117,6 +112,9 @@ func bcryptHash(data []byte) (string, error) {
}

func newHtpasswdUserCache(userFile string, syncInterval time.Duration) *htpasswdUserCache {
if userFile == "" {
userFile = "/etc/apache2/.htpasswd"
}
stopCtx, cancel := context.WithCancel(context.Background())
userFileObject, err := htpasswd.New(userFile, htpasswd.DefaultSystems, nil)
if err != nil {
Expand Down Expand Up @@ -187,33 +185,30 @@ func (huc *htpasswdUserCache) Match(username string, password string) bool {
return huc.userFileObject.Match(username, password)
}

func newEtcdUserCache(cluster cluster.Cluster, etcdConfig *EtcdSpec) *etcdUserCache {
func newEtcdUserCache(cluster cluster.Cluster, etcdPrefix string) *etcdUserCache {
prefix := customDataPrefix
if etcdConfig.Prefix == "" {
if etcdPrefix == "" {
prefix += "credentials/"
} else {
prefix += strings.TrimPrefix(etcdConfig.Prefix, "/")
prefix = customDataPrefix + strings.TrimPrefix(etcdPrefix, "/")
}
logger.Infof("credentials etcd prefix %s", prefix)
kvs, err := cluster.GetPrefix(prefix)
if err != nil {
panic(err)
}
pwReader, err := kvsToReader(kvs, etcdConfig.UsernameKey, etcdConfig.PasswordKey)
if err != nil {
logger.Errorf(err.Error())
return &etcdUserCache{}
}
pwReader := kvsToReader(kvs)
userFileObject, err := htpasswd.NewFromReader(pwReader, htpasswd.DefaultSystems, nil)
if err != nil {
panic(err)
logger.Errorf(err.Error())
return &etcdUserCache{}
}
stopCtx, cancel := context.WithCancel(context.Background())
return &etcdUserCache{
userFileObject: userFileObject,
cluster: cluster,
prefix: prefix,
usernameKey: etcdConfig.UsernameKey,
passwordKey: etcdConfig.PasswordKey,
cancel: cancel,
stopCtx: stopCtx,
// cluster.Syncer updates changes (removed access or updated passwords) immediately.
Expand All @@ -234,34 +229,42 @@ func parseYamlCreds(entry string) (map[string]interface{}, error) {
return credentials, err
}

func kvsToReader(kvs map[string]string, usernameKey string, passwordKey string) (io.Reader, error) {
reader := bytes.NewReader([]byte(""))
func kvsToReader(kvs map[string]string) io.Reader {
pwStrSlice := make([]string, 0, len(kvs))
for _, yaml := range kvs {
credentials, err := parseYamlCreds(yaml)
if err != nil {
return reader, err
logger.Errorf(err.Error())
continue
}
var ok bool
username, ok := credentials[usernameKey]
username, ok := credentials[etcdUsernameKey]
if !ok {
return reader,
fmt.Errorf("Parsing password updates failed. Make sure that '" +
usernameKey + "' is a valid yaml entry.")
logger.Errorf("Parsing credential updates failed. Make sure that credentials contains '" +
etcdUsernameKey + "' entry.")
continue
}
password, ok := credentials[passwordKey]
password, ok := credentials[etcdPasswordKey]
if !ok {
return reader,
fmt.Errorf("Parsing password updates failed. Make sure that '" +
passwordKey + "' is a valid yaml entry.")
logger.Errorf("Parsing credential updates failed. Make sure that credentials contains '" +
etcdPasswordKey + "' entry.")
continue
}
pwStrSlice = append(pwStrSlice, username.(string)+":"+password.(string))
}
if len(pwStrSlice) == 0 {
// no credentials found, let's return empty reader
return bytes.NewReader([]byte(""))
}
stringData := strings.Join(pwStrSlice, "\n")
return strings.NewReader(stringData), nil
return strings.NewReader(stringData)
}

func (euc *etcdUserCache) WatchChanges() error {
if euc.prefix == "" {
logger.Errorf("missing etcd prefix, skip watching changes")
return nil
}
var (
syncer *cluster.Syncer
err error
Expand Down Expand Up @@ -293,38 +296,42 @@ func (euc *etcdUserCache) WatchChanges() error {
return nil
case kvs := <-ch:
logger.Infof("basic auth credentials update")
pwReader, err := kvsToReader(kvs, euc.usernameKey, euc.passwordKey)
if err != nil {
logger.Errorf(err.Error())
}
pwReader := kvsToReader(kvs)
euc.userFileObject.ReloadFromReader(pwReader, nil)
}
}
return nil
}

func (euc *etcdUserCache) Close() {
if euc.prefix == "" {
return
}
euc.cancel()
}

func (euc *etcdUserCache) Refresh() error { return nil }

func (euc *etcdUserCache) Match(username string, password string) bool {
if euc.prefix == "" {
return false
}
return euc.userFileObject.Match(username, password)
}

// NewBasicAuthValidator creates a new Basic Auth validator
func NewBasicAuthValidator(spec *BasicAuthValidatorSpec, supervisor *supervisor.Supervisor) *BasicAuthValidator {
var cache AuthorizedUsersCache
if spec.Etcd != nil {
switch spec.Mode {
case "ETCD":
if supervisor == nil || supervisor.Cluster() == nil {
logger.Errorf("BasicAuth validator : failed to read data from etcd")
} else {
cache = newEtcdUserCache(supervisor.Cluster(), spec.Etcd)
return nil
}
} else if spec.UserFile != "" {
cache = newEtcdUserCache(supervisor.Cluster(), spec.EtcdPrefix)
case "FILE":
cache = newHtpasswdUserCache(spec.UserFile, 1*time.Minute)
} else {
default:
logger.Errorf("BasicAuth validator spec unvalid.")
return nil
}
Expand Down
24 changes: 8 additions & 16 deletions pkg/filter/validator/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ func TestBasicAuth(t *testing.T) {
kind: Validator
name: validator
basicAuth:
mode: FILE
userFile: ` + userFile.Name()

userFile.Write(
Expand Down Expand Up @@ -387,22 +388,15 @@ basicAuth:
clusterInstance := cluster.CreateClusterForTest(etcdDirName)

// Test newEtcdUserCache
if euc := newEtcdUserCache(clusterInstance, &EtcdSpec{
UsernameKey: "user",
PasswordKey: "pass",
}); euc.prefix != "/custom-data/credentials/" {
if euc := newEtcdUserCache(clusterInstance, ""); euc.prefix != "/custom-data/credentials/" {
t.Errorf("newEtcdUserCache failed")
}
if euc := newEtcdUserCache(clusterInstance, &EtcdSpec{
Prefix: "/extra-slash/",
UsernameKey: "user",
PasswordKey: "pass",
}); euc.prefix != "/custom-data/extra-slash/" {
if euc := newEtcdUserCache(clusterInstance, "/extra-slash/"); euc.prefix != "/custom-data/extra-slash/" {
t.Errorf("newEtcdUserCache failed")
}

pwToYaml := func(user string, pw string) string {
return fmt.Sprintf("username: %s\npassword: %s", user, pw)
return fmt.Sprintf("key: %s\npassword: %s", user, pw)
}
clusterInstance.Put("/custom-data/credentials/1", pwToYaml(userIds[0], encryptedPasswords[0]))
clusterInstance.Put("/custom-data/credentials/2", pwToYaml(userIds[2], encryptedPasswords[2]))
Expand All @@ -415,11 +409,9 @@ basicAuth:
kind: Validator
name: validator
basicAuth:
etcd:
prefix: credentials/
usernameKey: "username"
passwordKey: "password"`

mode: ETCD
etcdPrefix: credentials/
`
expectedValid := []bool{true, false, true}
v := createValidator(yamlSpec, nil, supervisor)
for i := 0; i < 3; i++ {
Expand All @@ -445,7 +437,7 @@ randomEntry1: 21
nestedEntry:
key1: val1
password: doge
username: doge
key: doge
lastEntry: "byebye"
`)

Expand Down