[FEATURE] Config Validation (#901)

* [FEATURE] Config Validation

* check configuration for invalid keys on startup
* allow users to manually trigger all configuration validation on a file using a cmd
* setup all defaults in config template and run tests against it to prevent accidents
* use tests to check bad configuration values are caught
* use tests to check old configuration values are caught
* add tests for specific key errors
* resolve merge conflicts
* nolint prealloc for test
This commit is contained in:
James Elliott 2020-04-23 11:47:27 +10:00 committed by GitHub
parent b9fb33d806
commit c1ac25a15b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 478 additions and 6 deletions

View File

@ -130,8 +130,9 @@ func main() {
}, },
} }
rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd) rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd,
rootCmd.AddCommand(commands.CertificatesCmd) commands.ValidateConfigCmd, commands.CertificatesCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -382,10 +382,10 @@ notifier:
# {title} is replaced by the text from the notifier # {title} is replaced by the text from the notifier
subject: "[Authelia] {title}" subject: "[Authelia] {title}"
# This address is used during the startup check to verify the email configuration is correct. It's not important what it is except if your email server only allows local delivery. # This address is used during the startup check to verify the email configuration is correct. It's not important what it is except if your email server only allows local delivery.
## startup_check_address: test@authelia.com startup_check_address: test@authelia.com
## trusted_cert: "" trusted_cert: ""
## disable_require_tls: false disable_require_tls: false
## disable_verify_cert: false disable_verify_cert: false
# Sending an email using a Gmail account is as simple as the next section. # Sending an email using a Gmail account is as simple as the next section.
# You need to create an app password by following: https://support.google.com/accounts/answer/185833?hl=en # You need to create an app password by following: https://support.google.com/accounts/answer/185833?hl=en

View File

@ -14,7 +14,23 @@ When running **Authelia**, you can specify your configuration by passing
the file path as shown below. the file path as shown below.
$ authelia --config config.custom.yml $ authelia --config config.custom.yml
## Validation
Authelia validates the configuration when it starts. This process checks multiple factors including configuration keys
that don't exist, configuration keys that have changed, the values of the keys are valid, and that a configuration
key isn't supplied at the same time as a secret for the same configuration option.
You may also optionally validate your configuration against this validation process manually by using the validate-config
option with the Authelia binary as shown below. Keep in mind if you're using [secrets](./secrets.md) you will have to
manually provide these if you don't want to get certain validation errors (specifically requesting you provide one of
the secret values). You can choose to ignore them if you know what you're doing. This command is useful prior to
upgrading to prevent configuration changes from impacting downtime in an upgrade.
$ authelia validate-config configuration.yml
## Duration Notation Format ## Duration Notation Format
We have implemented a string based notation for configuration options that take a duration. This section describes its We have implemented a string based notation for configuration options that take a duration. This section describes its

1
go.sum
View File

@ -458,6 +458,7 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY= golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=

View File

@ -0,0 +1,40 @@
package commands
import (
"fmt"
"log"
"os"
"github.com/spf13/cobra"
"github.com/authelia/authelia/internal/configuration"
)
// ValidateConfigCmd uses the internal configuration reader to validate the configuration.
var ValidateConfigCmd = &cobra.Command{
Use: "validate-config [yaml]",
Short: "Check a configuration against the internal configuration validation mechanisms.",
Run: func(cobraCmd *cobra.Command, args []string) {
configPath := args[0]
if _, err := os.Stat(configPath); err != nil {
log.Fatalf("Error Loading Configuration: %s\n", err)
}
// TODO: Actually use the configuration to validate some providers like Notifier
_, errs := configuration.Read(configPath)
if len(errs) != 0 {
str := "Errors"
if len(errs) == 1 {
str = "Error"
}
errors := ""
for _, err := range errs {
errors += fmt.Sprintf("\t%s\n", err.Error())
}
log.Fatalf("%s occurred parsing configuration:\n%s", str, errors)
} else {
log.Println("Configuration parsed successfully without errors.")
}
},
Args: cobra.MinimumNArgs(1),
}

View File

@ -48,6 +48,7 @@ func Read(configPath string) (*schema.Configuration, []error) {
val := schema.NewStructValidator() val := schema.NewStructValidator()
validator.ValidateSecrets(&configuration, val, viper.GetViper()) validator.ValidateSecrets(&configuration, val, viper.GetViper())
validator.ValidateConfiguration(&configuration, val) validator.ValidateConfiguration(&configuration, val)
validator.ValidateKeys(val, viper.AllKeys())
if val.HasErrors() { if val.HasErrors() {
return nil, val.Errors() return nil, val.Errors()

View File

@ -2,6 +2,7 @@ package configuration
import ( import (
"os" "os"
"sort"
"strings" "strings"
"testing" "testing"
@ -29,6 +30,7 @@ func TestShouldParseConfigFile(t *testing.T) {
require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD", "smtp_secret_from_env")) require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD", "smtp_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD", "redis_secret_from_env")) require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD", "redis_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD", "mysql_secret_from_env")) require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD", "mysql_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD", "postgres_secret_from_env"))
config, errors := Read("./test_resources/config.yml") config, errors := Read("./test_resources/config.yml")
@ -73,6 +75,33 @@ func TestShouldParseAltConfigFile(t *testing.T) {
assert.Len(t, config.AccessControl.Rules, 12) assert.Len(t, config.AccessControl.Rules, 12)
} }
func TestShouldNotParseConfigFileWithOldOrUnexpectedKeys(t *testing.T) {
require.NoError(t, os.Setenv("AUTHELIA_JWT_SECRET", "secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_DUO_API_SECRET_KEY", "duo_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_SESSION_SECRET", "session_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD", "ldap_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD", "smtp_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD", "redis_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD", "mysql_secret_from_env"))
require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD", "postgres_secret_from_env"))
_, errors := Read("./test_resources/config_bad_keys.yml")
require.Len(t, errors, 2)
// Sort error slice to prevent shenanigans that somehow occur
sort.Slice(errors, func(i, j int) bool {
return errors[i].Error() < errors[j].Error()
})
assert.EqualError(t, errors[0], "config key not expected: loggy_file")
assert.EqualError(t, errors[1], "config key replaced: logs_level is now log_level")
}
func TestShouldValidateConfigurationTemplate(t *testing.T) {
resetEnv()
_, errors := Read("../../config.template.yml")
assert.Len(t, errors, 0)
}
func TestShouldOnlyAllowOneEnvType(t *testing.T) { func TestShouldOnlyAllowOneEnvType(t *testing.T) {
resetEnv() resetEnv()
require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD", "postgres_secret_from_env")) require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD", "postgres_secret_from_env"))

View File

@ -0,0 +1,124 @@
###############################################################
# Authelia configuration #
###############################################################
host: 127.0.0.1
port: 9091
loggy_file: /etc/authelia/svc.log
logs_level: debug
default_redirection_url: https://home.example.com:8080/
totp:
issuer: authelia.com
duo_api:
hostname: api-123456789.example.com
integration_key: ABCDEF
authentication_backend:
ldap:
url: ldap://127.0.0.1
base_dn: dc=example,dc=com
username_attribute: uid
additional_users_dn: ou=users
users_filter: (&({username_attribute}={input})(objectCategory=person)(objectClass=user))
additional_groups_dn: ou=groups
groups_filter: (&(member={dn})(objectclass=groupOfNames))
group_name_attribute: cn
mail_attribute: mail
user: cn=admin,dc=example,dc=com
access_control:
default_policy: deny
rules:
# Rules applied to everyone
- domain: public.example.com
policy: bypass
- domain: secure.example.com
policy: one_factor
# Network based rule, if not provided any network matches.
networks:
- 192.168.1.0/24
- domain: secure.example.com
policy: two_factor
- domain: [singlefactor.example.com, onefactor.example.com]
policy: one_factor
# Rules applied to 'admins' group
- domain: "mx2.mail.example.com"
subject: "group:admins"
policy: deny
- domain: "*.example.com"
subject: "group:admins"
policy: two_factor
# Rules applied to 'dev' group
- domain: dev.example.com
resources:
- "^/groups/dev/.*$"
subject: "group:dev"
policy: two_factor
# Rules applied to user 'john'
- domain: dev.example.com
resources:
- "^/users/john/.*$"
subject: "user:john"
policy: two_factor
# Rules applied to 'dev' group and user 'john'
- domain: dev.example.com
resources:
- "^/deny-all.*$"
subject: ["group:dev", "user:john"]
policy: denied
# Rules applied to user 'harry'
- domain: dev.example.com
resources:
- "^/users/harry/.*$"
subject: "user:harry"
policy: two_factor
# Rules applied to user 'bob'
- domain: "*.mail.example.com"
subject: "user:bob"
policy: two_factor
- domain: "dev.example.com"
resources:
- "^/users/bob/.*$"
subject: "user:bob"
policy: two_factor
session:
name: authelia_session
expiration: 3600000 # 1 hour
inactivity: 300000 # 5 minutes
domain: example.com
redis:
host: 127.0.0.1
port: 6379
regulation:
max_retries: 3
find_time: 120
ban_time: 300
storage:
mysql:
host: 127.0.0.1
port: 3306
database: authelia
username: authelia
notifier:
smtp:
username: test
host: 127.0.0.1
port: 1025
sender: admin@example.com
disable_require_tls: true

View File

@ -0,0 +1,148 @@
package validator
var validKeys = []string{
// Root Keys.
"host",
"port",
"log_level",
"log_file_path",
"default_redirection_url",
"jwt_secret",
"tls_key",
"tls_cert",
"google_analytics",
// TOTP Keys
"totp.issuer",
"totp.period",
"totp.skew",
// Access Control Keys
"access_control.rules",
"access_control.default_policy",
// Session Keys.
"session.name",
"session.secret",
"session.expiration",
"session.inactivity",
"session.remember_me_duration",
"session.domain",
// Redis Session Keys.
"session.redis.host",
"session.redis.port",
"session.redis.password",
"session.redis.database_index",
// Local Storage Keys.
"storage.local.path",
// MySQL Storage Keys.
"storage.mysql.host",
"storage.mysql.port",
"storage.mysql.database",
"storage.mysql.username",
"storage.mysql.password",
// PostgreSQL Storage Keys.
"storage.postgres.host",
"storage.postgres.port",
"storage.postgres.database",
"storage.postgres.username",
"storage.postgres.password",
"storage.postgres.sslmode",
// FileSystem Notifier Keys.
"notifier.filesystem.filename",
"notifier.disable_startup_check",
// SMTP Notifier Keys.
"notifier.smtp.username",
"notifier.smtp.password",
"notifier.smtp.host",
"notifier.smtp.port",
"notifier.smtp.sender",
"notifier.smtp.subject",
"notifier.smtp.startup_check_address",
"notifier.smtp.disable_require_tls",
"notifier.smtp.disable_verify_cert",
"notifier.smtp.trusted_cert",
// Regulation Keys.
"regulation.max_retries",
"regulation.find_time",
"regulation.ban_time",
// DUO API Keys.
"duo_api.hostname",
"duo_api.integration_key",
"duo_api.secret_key",
// Authentication Backend Keys.
"authentication_backend.disable_reset_password",
// LDAP Authentication Backend Keys.
"authentication_backend.ldap.url",
"authentication_backend.ldap.skip_verify",
"authentication_backend.ldap.base_dn",
"authentication_backend.ldap.username_attribute",
"authentication_backend.ldap.additional_users_dn",
"authentication_backend.ldap.users_filter",
"authentication_backend.ldap.additional_groups_dn",
"authentication_backend.ldap.groups_filter",
"authentication_backend.ldap.group_name_attribute",
"authentication_backend.ldap.mail_attribute",
"authentication_backend.ldap.user",
"authentication_backend.ldap.password",
// File Authentication Backend Keys.
"authentication_backend.file.path",
"authentication_backend.file.password.algorithm",
"authentication_backend.file.password.iterations",
"authentication_backend.file.password.key_length",
"authentication_backend.file.password.salt_length",
"authentication_backend.file.password.memory",
"authentication_backend.file.password.parallelism",
// Secret Keys.
"authelia.jwt_secret",
"authelia.duo_api.secret_key",
"authelia.session.secret",
"authelia.authentication_backend.ldap.password",
"authelia.notifier.smtp.password",
"authelia.session.redis.password",
"authelia.storage.mysql.password",
"authelia.storage.postgres.password",
"authelia.jwt_secret.file",
"authelia.duo_api.secret_key.file",
"authelia.session.secret.file",
"authelia.authentication_backend.ldap.password.file",
"authelia.notifier.smtp.password.file",
"authelia.session.redis.password.file",
"authelia.storage.mysql.password.file",
"authelia.storage.postgres.password.file",
}
var specificErrorKeys = map[string]string{
"logs_file_path": "config key replaced: logs_file is now log_file",
"logs_level": "config key replaced: logs_level is now log_level",
"authentication_backend.file.password_options.algorithm": "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password",
"authentication_backend.file.password_options.iterations": "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password",
"authentication_backend.file.password_options.key_length": "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password",
"authentication_backend.file.password_options.salt_length": "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password",
"authentication_backend.file.password_options.memory": "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password",
"authentication_backend.file.password_options.parallelism": "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password",
"authentication_backend.file.password_hashing.algorithm": "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password",
"authentication_backend.file.password_hashing.iterations": "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password",
"authentication_backend.file.password_hashing.key_length": "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password",
"authentication_backend.file.password_hashing.salt_length": "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password",
"authentication_backend.file.password_hashing.memory": "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password",
"authentication_backend.file.password_hashing.parallelism": "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password",
"authentication_backend.file.hashing.algorithm": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
"authentication_backend.file.hashing.iterations": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
"authentication_backend.file.hashing.key_length": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
"authentication_backend.file.hashing.salt_length": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
"authentication_backend.file.hashing.memory": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
"authentication_backend.file.hashing.parallelism": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
}

View File

@ -0,0 +1,29 @@
package validator
import (
"errors"
"fmt"
"github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/utils"
)
func ValidateKeys(validator *schema.StructValidator, keys []string) {
var errStrings []string
for _, key := range keys {
if utils.IsStringInSlice(key, validKeys) {
continue
}
if err, ok := specificErrorKeys[key]; ok {
if !utils.IsStringInSlice(err, errStrings) {
errStrings = append(errStrings, err)
}
} else {
validator.Push(fmt.Errorf("config key not expected: %s", key))
}
}
for _, err := range errStrings {
validator.Push(errors.New(err))
}
}

View File

@ -0,0 +1,83 @@
package validator
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/utils"
)
func TestShouldValidateGoodKeys(t *testing.T) {
configKeys := validKeys
val := schema.NewStructValidator()
ValidateKeys(val, configKeys)
require.Len(t, val.Errors(), 0)
}
func TestShouldNotValidateBadKeys(t *testing.T) {
configKeys := validKeys
configKeys = append(configKeys, "bad_key")
configKeys = append(configKeys, "totp.skewy")
val := schema.NewStructValidator()
ValidateKeys(val, configKeys)
errs := val.Errors()
require.Len(t, errs, 2)
assert.EqualError(t, errs[0], "config key not expected: bad_key")
assert.EqualError(t, errs[1], "config key not expected: totp.skewy")
}
func TestAllSpecificErrorKeys(t *testing.T) {
var configKeys []string //nolint:prealloc // This is because the test is dynamic based on the keys that exist in the map
var uniqueValues []string
// Setup configKeys and uniqueValues expected.
for key, value := range specificErrorKeys {
configKeys = append(configKeys, key)
if !utils.IsStringInSlice(value, uniqueValues) {
uniqueValues = append(uniqueValues, value)
}
}
val := schema.NewStructValidator()
ValidateKeys(val, configKeys)
errs := val.Errors()
// Check only unique errors are shown. Require because if we don't the next test panics.
require.Len(t, errs, len(uniqueValues))
// Dynamically check all specific errors.
for i, value := range uniqueValues {
assert.EqualError(t, errs[i], value)
}
}
func TestSpecificErrorKeys(t *testing.T) {
configKeys := []string{
"logs_level",
"logs_file_path",
"authentication_backend.file.password_options.algorithm",
"authentication_backend.file.password_options.iterations", // This should not show another error since our target for the specific error is password_options.
"authentication_backend.file.password_hashing.algorithm",
"authentication_backend.file.hashing.algorithm",
}
val := schema.NewStructValidator()
ValidateKeys(val, configKeys)
errs := val.Errors()
require.Len(t, errs, 5)
assert.EqualError(t, errs[0], specificErrorKeys["logs_level"])
assert.EqualError(t, errs[1], specificErrorKeys["logs_file_path"])
assert.EqualError(t, errs[2], specificErrorKeys["authentication_backend.file.password_options.iterations"])
assert.EqualError(t, errs[3], specificErrorKeys["authentication_backend.file.password_hashing.algorithm"])
assert.EqualError(t, errs[4], specificErrorKeys["authentication_backend.file.hashing.algorithm"])
}