diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 9b93cc38..6b740c34 100644 --- a/cmd/authelia/main.go +++ b/cmd/authelia/main.go @@ -130,8 +130,9 @@ func main() { }, } - rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd) - rootCmd.AddCommand(commands.CertificatesCmd) + rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd, + commands.ValidateConfigCmd, commands.CertificatesCmd) + if err := rootCmd.Execute(); err != nil { log.Fatal(err) } diff --git a/config.template.yml b/config.template.yml index de79839a..881c6d6a 100644 --- a/config.template.yml +++ b/config.template.yml @@ -382,10 +382,10 @@ notifier: # {title} is replaced by the text from the notifier 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. - ## startup_check_address: test@authelia.com - ## trusted_cert: "" - ## disable_require_tls: false - ## disable_verify_cert: false + startup_check_address: test@authelia.com + trusted_cert: "" + disable_require_tls: false + disable_verify_cert: false # 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 diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 2207d3e3..849c3773 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -14,7 +14,23 @@ When running **Authelia**, you can specify your configuration by passing the file path as shown below. $ 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 We have implemented a string based notation for configuration options that take a duration. This section describes its diff --git a/go.sum b/go.sum index e2133873..682a9eee 100644 --- a/go.sum +++ b/go.sum @@ -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-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-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= 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.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= diff --git a/internal/commands/validate.go b/internal/commands/validate.go new file mode 100644 index 00000000..73a9c65b --- /dev/null +++ b/internal/commands/validate.go @@ -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), +} diff --git a/internal/configuration/reader.go b/internal/configuration/reader.go index f8c305ae..f963fe55 100644 --- a/internal/configuration/reader.go +++ b/internal/configuration/reader.go @@ -48,6 +48,7 @@ func Read(configPath string) (*schema.Configuration, []error) { val := schema.NewStructValidator() validator.ValidateSecrets(&configuration, val, viper.GetViper()) validator.ValidateConfiguration(&configuration, val) + validator.ValidateKeys(val, viper.AllKeys()) if val.HasErrors() { return nil, val.Errors() diff --git a/internal/configuration/reader_test.go b/internal/configuration/reader_test.go index 0bc6cffa..75a716c4 100644 --- a/internal/configuration/reader_test.go +++ b/internal/configuration/reader_test.go @@ -2,6 +2,7 @@ package configuration import ( "os" + "sort" "strings" "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_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")) config, errors := Read("./test_resources/config.yml") @@ -73,6 +75,33 @@ func TestShouldParseAltConfigFile(t *testing.T) { 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) { resetEnv() require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD", "postgres_secret_from_env")) diff --git a/internal/configuration/test_resources/config_bad_keys.yml b/internal/configuration/test_resources/config_bad_keys.yml new file mode 100644 index 00000000..8f1b5558 --- /dev/null +++ b/internal/configuration/test_resources/config_bad_keys.yml @@ -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 \ No newline at end of file diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go new file mode 100644 index 00000000..70f6d341 --- /dev/null +++ b/internal/configuration/validator/const.go @@ -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", +} diff --git a/internal/configuration/validator/keys.go b/internal/configuration/validator/keys.go new file mode 100644 index 00000000..6d07b7cb --- /dev/null +++ b/internal/configuration/validator/keys.go @@ -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)) + } +} diff --git a/internal/configuration/validator/keys_test.go b/internal/configuration/validator/keys_test.go new file mode 100644 index 00000000..d5b156ce --- /dev/null +++ b/internal/configuration/validator/keys_test.go @@ -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"]) +}