diff --git a/cmd/authelia-scripts/helpers.go b/cmd/authelia-scripts/helpers.go index 2b71d1e6..79157600 100644 --- a/cmd/authelia-scripts/helpers.go +++ b/cmd/authelia-scripts/helpers.go @@ -12,7 +12,7 @@ func getXFlags(branch, build, extra string) (flags []string, err error) { if branch == "" { out, _, err := utils.RunCommandAndReturnOutput("git rev-parse --abbrev-ref HEAD") if err != nil { - return flags, err + return flags, fmt.Errorf("error getting branch with git rev-parse: %w", err) } if out == "" { @@ -24,17 +24,17 @@ func getXFlags(branch, build, extra string) (flags []string, err error) { gitTagCommit, _, err := utils.RunCommandAndReturnOutput("git rev-list --tags --max-count=1") if err != nil { - return flags, err + return flags, fmt.Errorf("error getting tag commit with git rev-list: %w", err) } - tag, _, err := utils.RunCommandAndReturnOutput("git describe --tags --abbrev=0 " + gitTagCommit) + tag, _, err := utils.RunCommandAndReturnOutput(fmt.Sprintf("git describe --tags --abbrev=0 %s", gitTagCommit)) if err != nil { - return flags, err + return flags, fmt.Errorf("error getting tag with git describe: %w", err) } commit, _, err := utils.RunCommandAndReturnOutput("git rev-parse HEAD") if err != nil { - return flags, err + return flags, fmt.Errorf("error getting commit with git rev-parse: %w", err) } var states []string diff --git a/cmd/authelia-scripts/main.go b/cmd/authelia-scripts/main.go index f2ce8340..d745d859 100755 --- a/cmd/authelia-scripts/main.go +++ b/cmd/authelia-scripts/main.go @@ -136,7 +136,7 @@ func main() { cobraCommands = append(cobraCommands, command) } - cobraCommands = append(cobraCommands, commands.HashPasswordCmd, commands.CertificatesCmd, commands.RSACmd, xflagsCmd) + cobraCommands = append(cobraCommands, commands.NewHashPasswordCmd(), commands.NewCertificatesCmd(), commands.NewRSACmd(), xflagsCmd) rootCmd.PersistentFlags().BoolVar(&buildkite, "buildkite", false, "Set CI flag for Buildkite") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Set the log level for the command") diff --git a/cmd/authelia/const.go b/cmd/authelia/const.go deleted file mode 100644 index 296025de..00000000 --- a/cmd/authelia/const.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -const fmtAutheliaLong = `authelia %s - -An open-source authentication and authorization server providing -two-factor authentication and single sign-on (SSO) for your -applications via a web portal. - -Documentation is available at: https://www.authelia.com/docs -` - -const fmtAutheliaBuild = `Last Tag: %s -State: %s -Branch: %s -Commit: %s -Build Number: %s -Build OS: %s -Build Arch: %s -Build Date: %s -Extra: %s -` diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 8b7879cd..c56a7d96 100644 --- a/cmd/authelia/main.go +++ b/cmd/authelia/main.go @@ -1,188 +1,14 @@ package main import ( - "fmt" - "os" - "runtime" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/authelia/authelia/internal/authentication" - "github.com/authelia/authelia/internal/authorization" "github.com/authelia/authelia/internal/commands" - "github.com/authelia/authelia/internal/configuration" "github.com/authelia/authelia/internal/logging" - "github.com/authelia/authelia/internal/middlewares" - "github.com/authelia/authelia/internal/notification" - "github.com/authelia/authelia/internal/oidc" - "github.com/authelia/authelia/internal/regulation" - "github.com/authelia/authelia/internal/server" - "github.com/authelia/authelia/internal/session" - "github.com/authelia/authelia/internal/storage" - "github.com/authelia/authelia/internal/utils" ) -var configPathFlag string - -//nolint:gocyclo // TODO: Consider refactoring/simplifying, time permitting. -func startServer() { - logger := logging.Logger() - config, errs := configuration.Read(configPathFlag) - - if len(errs) > 0 { - for _, err := range errs { - logger.Error(err) - } - - os.Exit(1) - } - - autheliaCertPool, errs, nonFatalErrs := utils.NewX509CertPool(config.CertificatesDirectory) - if len(errs) > 0 { - for _, err := range errs { - logger.Error(err) - } - - os.Exit(2) - } - - if len(nonFatalErrs) > 0 { - for _, err := range nonFatalErrs { - logger.Warn(err) - } - } - - if err := logging.InitializeLogger(config.Logging.Format, config.Logging.FilePath, config.Logging.KeepStdout); err != nil { - logger.Fatalf("Cannot initialize logger: %v", err) - } - - logger.Infof("Authelia %s is starting", utils.Version()) - - switch config.Logging.Level { - case "error": - logger.Info("Logging severity set to error") - logging.SetLevel(logrus.ErrorLevel) - case "warn": - logger.Info("Logging severity set to warn") - logging.SetLevel(logrus.WarnLevel) - case "info": - logger.Info("Logging severity set to info") - logging.SetLevel(logrus.InfoLevel) - case "debug": - logger.Info("Logging severity set to debug") - logging.SetLevel(logrus.DebugLevel) - case "trace": - logger.Info("Logging severity set to trace") - logging.SetLevel(logrus.TraceLevel) - } - - if os.Getenv("ENVIRONMENT") == "dev" { - logger.Info("===> Authelia is running in development mode. <===") - } - - var storageProvider storage.Provider - - switch { - case config.Storage.PostgreSQL != nil: - storageProvider = storage.NewPostgreSQLProvider(*config.Storage.PostgreSQL) - case config.Storage.MySQL != nil: - storageProvider = storage.NewMySQLProvider(*config.Storage.MySQL) - case config.Storage.Local != nil: - storageProvider = storage.NewSQLiteProvider(config.Storage.Local.Path) - default: - logger.Fatalf("Unrecognized storage backend") - } - - var ( - userProvider authentication.UserProvider - err error - ) - - switch { - case config.AuthenticationBackend.File != nil: - userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) - case config.AuthenticationBackend.LDAP != nil: - userProvider, err = authentication.NewLDAPUserProvider(config.AuthenticationBackend, autheliaCertPool) - if err != nil { - logger.Fatalf("Failed to Check LDAP Authentication Backend: %v", err) - } - default: - logger.Fatalf("Unrecognized authentication backend") - } - - var notifier notification.Notifier - - switch { - case config.Notifier.SMTP != nil: - notifier = notification.NewSMTPNotifier(*config.Notifier.SMTP, autheliaCertPool) - case config.Notifier.FileSystem != nil: - notifier = notification.NewFileNotifier(*config.Notifier.FileSystem) - default: - logger.Fatalf("Unrecognized notifier") - } - - if !config.Notifier.DisableStartupCheck { - _, err = notifier.StartupCheck() - if err != nil { - logger.Fatalf("Error during notifier startup check: %s", err) - } - } - - clock := utils.RealClock{} - authorizer := authorization.NewAuthorizer(config) - sessionProvider := session.NewProvider(config.Session, autheliaCertPool) - regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock) - - oidcProvider, err := oidc.NewOpenIDConnectProvider(config.IdentityProviders.OIDC) - if err != nil { - logger.Fatalf("Error initializing OpenID Connect Provider: %+v", err) - } - - providers := middlewares.Providers{ - Authorizer: authorizer, - UserProvider: userProvider, - Regulator: regulator, - OpenIDConnect: oidcProvider, - StorageProvider: storageProvider, - Notifier: notifier, - SessionProvider: sessionProvider, - } - - server.StartServer(*config, providers) -} - func main() { logger := logging.Logger() - version := utils.Version() - - rootCmd := &cobra.Command{ - Use: "authelia", - Run: func(cmd *cobra.Command, args []string) { - startServer() - }, - Version: version, - Short: fmt.Sprintf("authelia %s", version), - Long: fmt.Sprintf(fmtAutheliaLong, version), - } - - rootCmd.Flags().StringVar(&configPathFlag, "config", "", "Configuration file") - - buildCmd := &cobra.Command{ - Use: "build", - Short: "Show the build of Authelia", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf(fmtAutheliaBuild, utils.BuildTag, utils.BuildState, utils.BuildBranch, utils.BuildCommit, - utils.BuildNumber, runtime.GOOS, runtime.GOARCH, utils.BuildDate, utils.BuildExtra) - }, - } - - rootCmd.AddCommand(buildCmd, commands.HashPasswordCmd, - commands.ValidateConfigCmd, commands.CertificatesCmd, - commands.RSACmd) - - if err := rootCmd.Execute(); err != nil { + if err := commands.NewRootCmd().Execute(); err != nil { logger.Fatal(err) } } diff --git a/config.template.yml b/config.template.yml index d8d877f8..c552a45a 100644 --- a/config.template.yml +++ b/config.template.yml @@ -29,21 +29,16 @@ default_redirection_url: https://home.example.com/ ## Server Configuration ## server: + ## The address to listen on. host: 0.0.0.0 ## The port to listen on. port: 9091 - ## Authelia by default doesn't accept TLS communication on the server port. This section overrides this behaviour. - tls: - ## The path to the DER base64/PEM format private key. - key: "" - # key: /config/ssl/key.pem - - ## The path to the DER base64/PEM format public certificate. - certificate: "" - # certificate: /config/ssl/cert.pem + ## Set the single level path Authelia listens on. + ## Must be alphanumeric chars and should not contain any slashes. + path: "" ## Buffers usually should be configured to be the same value. ## Explanation at https://www.authelia.com/docs/configuration/server.html @@ -52,16 +47,23 @@ server: read_buffer_size: 4096 write_buffer_size: 4096 - ## Set the single level path Authelia listens on. - ## Must be alphanumeric chars and should not contain any slashes. - path: "" - ## Enables the pprof endpoint. enable_pprof: false ## Enables the expvars endpoint. enable_expvars: false + ## Authelia by default doesn't accept TLS communication on the server port. This section overrides this behaviour. + tls: + ## The path to the DER base64/PEM format private key. + key: "" + + ## The path to the DER base64/PEM format public certificate. + certificate: "" + +## +## Log Configuration +## log: ## Level of verbosity for logs: info, debug, trace. level: debug diff --git a/docs/configuration/index.md b/docs/configuration/index.md index b5ef5046..4b317a70 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -6,17 +6,61 @@ has_children: true --- # Configuration +Authelia has several methods of configuration available to it. The order of precedence is as follows: -Authelia uses a YAML file as configuration file. A template with all possible options can be -found [here](https://github.com/authelia/authelia/blob/master/config.template.yml), at the root of the repository. +1. [Secrets](./secrets.md) +2. [Environment Variables](#environment) +3. [Files](#files) (in order of them being specified) +This order of precedence puts higher weight on things higher in the list. This means anything specified in the +[files](#files) is overridden by [environment variables](#environment) if specified, and anything specified by +[environment variables](#environment) is overridden by [secrets](./secrets.md) if specified. + +## Files When running **Authelia**, you can specify your configuration by passing the file path as shown below. ```console $ authelia --config config.custom.yml ``` -## Documentation +You can have multiple configuration files which will be merged in the order specified. If duplicate keys are specified +the last one to be specified is the one that takes precedence. Example: + +```console +$ authelia --config config.yml --config config-acl.yml --config config-other.yml +$ authelia --config config.yml,config-acl.yml,config-other.yml +``` + +Authelia's configuration files use the YAML format. A template with all possible options can be found at the root of the +repository [here](https://github.com/authelia/authelia/blob/master/config.template.yml). + +## Environment +You may also provide the configuration by using environment variables. Environment variables are applied after the +configuration file meaning anything specified as part of the environment overrides the configuration files. The +environment variables must be prefixed with `AUTHELIA_`. + +_**Please Note:** It is not possible to configure_ the _access control rules section or OpenID Connect identity provider +section using environment variables at this time._ + +_**Please Note:** There are compatability issues with Kubernetes and this particular configuration option. You must ensure you +have the `enableServiceLinks: false` setting in your pod spec._ + +Underscores replace indented configuration sections or subkeys. For example the following environment variables replace +the configuration snippet that follows it: + +``` +AUTHELIA_LOG_LEVEL=info +AUTHELIA_SERVER_READ_BUFFER_SIZE=4096 +``` + +```yaml +log: + level: info +server: + read_buffer_size: 4096 +``` + +# Documentation We document the configuration in two ways: @@ -33,7 +77,7 @@ We document the configuration in two ways: - The `required` label changes color. When required it will be red, when not required it will be green, when the required state depends on another configuration value it is yellow. -## Validation +# 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 @@ -50,7 +94,7 @@ integrations, it only checks that your configuration syntax is valid. $ 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 usage. You can use this implementation in: session for expiration, inactivity, and remember_me_duration; and regulation @@ -74,12 +118,12 @@ Examples: * 1 day: 1d * 10 hours: 10h -## TLS Configuration +# TLS Configuration Various sections of the configuration use a uniform configuration section called TLS. Notably LDAP and SMTP. This section documents the usage. -### Server Name +## Server Name
type: string {: .label .label-config .label-purple } @@ -92,7 +136,7 @@ required: no The key `server_name` overrides the name checked against the certificate in the verification process. Useful if you require to use a direct IP address for the address of the backend service but want to verify a specific SNI. -### Skip Verify +## Skip Verify
type: boolean {: .label .label-config .label-purple } @@ -103,9 +147,9 @@ required: no
The key `skip_verify` completely negates validating the certificate of the backend service. This is not recommended, -instead you should tweak the `server_name` option, and the global option [certificates_directory](./miscellaneous.md#certificates_directory). +instead you should tweak the `server_name` option, and the global option [certificates directory](./miscellaneous.md#certificates_directory). -### Minimum Version +## Minimum Version
type: string {: .label .label-config .label-purple } diff --git a/docs/configuration/secrets.md b/docs/configuration/secrets.md index 93e749a9..5a2d2dce 100644 --- a/docs/configuration/secrets.md +++ b/docs/configuration/secrets.md @@ -7,32 +7,30 @@ nav_order: 10 # Secrets -Configuration of Authelia requires some secrets and passwords. -Even if they can be set in the configuration file, the recommended -way to set secrets is to use environment variables as described -below. +Configuration of Authelia requires some secrets and passwords. Even if they can be set in the configuration file or +standard environment variables, the recommended way to set secrets is to use environment variables as described below. ## Environment variables -A secret can be configured using an environment variable with the -prefix AUTHELIA_ followed by the path of the option capitalized -and with dots replaced by underscores followed by the suffix _FILE. +A secret value can be loaded by Authelia when the configuration key ends with one of the following words: `key`, +`secret`, `password`, or `token`. -The contents of the environment variable must be a path to a file -containing the secret data. This file must be readable by the -user the Authelia daemon is running as. +If you take the expected environment variable for the configuration option with the `_FILE` suffix at the end. The value +of these environment variables must be the path of a file that is readable by the Authelia process, if they are not, +Authelia will fail to load. Authelia will automatically remove the newlines from the end of the files contents. For instance the LDAP password can be defined in the configuration at the path **authentication_backend.ldap.password**, so this password could alternatively be set using the environment variable called -**AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE**. +**AUTHELIA__AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE**. -Here is the list of the environment variables which are considered -secrets and can be defined. Any other option defined using an -environment variable will not be replaced. +Here is the list of the environment variables which are considered secrets and can be defined. Please note that only +secrets can be loaded into the configuration if they end with one of the suffixes above, you can set the value of any +other configuration using the environment but instead of loading a file the value of the environment variable is used. |Configuration Key |Environment Variable | |:-----------------------------------------------:|:------------------------------------------------------:| +|tls_key |AUTHELIA_TLS_KEY_FILE | |jwt_secret |AUTHELIA_JWT_SECRET_FILE | |duo_api.secret_key |AUTHELIA_DUO_API_SECRET_KEY_FILE | |session.secret |AUTHELIA_SESSION_SECRET_FILE | @@ -47,21 +45,21 @@ environment variable will not be replaced. ## Secrets in configuration file -If for some reason you prefer keeping the secrets in the configuration -file, be sure to apply the right permissions to the file in order to -prevent secret leaks if an another application gets compromised on your -server. The UNIX permissions should probably be something like 600. +If for some reason you decide on keeping the secrets in the configuration file, it is strongly recommended that you +ensure the permissions of the configuration file are appropriately set so that other users or processes cannot access +this file. Generally the UNIX permissions that are appropriate are 0600. ## Secrets exposed in an environment variable -**DEPRECATION NOTICE:** This backwards compatibility feature **has been removed** in 4.18.0+. +In all versions 4.30.0+ you can technically set secrets using the environment variables without the `_FILE` suffix by +setting the value to the value you wish to set in configuration, however we strongly urge people not to use this option +and instead use the file-based secrets above. -Prior to implementing file secrets you were able to define the -values of secrets in the environment variables themselves -in plain text instead of referencing a file. **This is no longer available -as an option**, please see the table above for the file based replacements. See -[this article](https://diogomonica.com/2017/03/27/why-you-shouldnt-use-env-variables-for-secret-data/) -for reasons why this was removed. +Prior to implementing file secrets the only way you were able to define secret values was either via configuration or +via environment variables in plain text. + +See [this article](https://diogomonica.com/2017/03/27/why-you-shouldnt-use-env-variables-for-secret-data/) for reasons +why setting them via the file counterparts is highly encouraged. ## Docker diff --git a/docs/configuration/server.md b/docs/configuration/server.md index 064b64a8..40172e93 100644 --- a/docs/configuration/server.md +++ b/docs/configuration/server.md @@ -15,9 +15,9 @@ The server section configures and tunes the http server module Authelia uses. server: host: 0.0.0.0 port: 9091 + path: "" read_buffer_size: 4096 write_buffer_size: 4096 - path: "" enable_pprof: false enable_expvars: false tls: @@ -58,30 +58,6 @@ required: no Defines the port to listen on. See also [host](#host). -### read_buffer_size -
-type: integer -{: .label .label-config .label-purple } -default: 4096 -{: .label .label-config .label-blue } -required: no -{: .label .label-config .label-green } -
- -Configures the maximum request size. The default of 4096 is generally sufficient for most use cases. - -### write_buffer_size -
-type: integer -{: .label .label-config .label-purple } -default: 4096 -{: .label .label-config .label-blue } -required: no -{: .label .label-config .label-green } -
- -Configures the maximum response size. The default of 4096 is generally sufficient for most use cases. - ### path
type: string @@ -110,6 +86,30 @@ server: path: authelia ``` +### read_buffer_size +
+type: integer +{: .label .label-config .label-purple } +default: 4096 +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +Configures the maximum request size. The default of 4096 is generally sufficient for most use cases. + +### write_buffer_size +
+type: integer +{: .label .label-config .label-purple } +default: 4096 +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +Configures the maximum response size. The default of 4096 is generally sufficient for most use cases. + ### enable_pprof
type: boolean diff --git a/go.mod b/go.mod index 55639345..f967aad5 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( github.com/golang/mock v1.6.0 github.com/google/uuid v1.3.0 github.com/jackc/pgx/v4 v4.13.0 + github.com/knadh/koanf v1.1.1 + github.com/mitchellh/mapstructure v1.4.1 github.com/ory/fosite v0.40.2 github.com/ory/herodot v0.9.7 github.com/otiai10/copy v1.6.0 @@ -26,7 +28,6 @@ require ( github.com/simia-tech/crypt v0.5.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.2.1 - github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.7.0 github.com/tebeka/selenium v0.9.9 github.com/tstranex/u2f v1.0.0 diff --git a/go.sum b/go.sum index 254c7c2a..6fbd332a 100644 --- a/go.sum +++ b/go.sum @@ -817,6 +817,7 @@ github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhB github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -854,6 +855,8 @@ github.com/klauspost/compress v1.12.2 h1:2KCfW3I9M7nSc5wOqXAlW2v2U6v+w6cbjvbfp+O github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/knadh/koanf v0.14.1-0.20201201075439-e0853799f9ec/go.mod h1:H5mEFsTeWizwFXHKtsITL5ipsLTuAMQoGuQpp+1JL9U= github.com/knadh/koanf v1.0.0/go.mod h1:vrMMuhIH0k7EoxiMbVfFlRvJYmxcT2Eha3DH8Tx5+X4= +github.com/knadh/koanf v1.1.1 h1:doO5UBvSXcmngdr/u54HKe+Uz4ZZw0/YHVzSsnE3vD4= +github.com/knadh/koanf v1.1.1/go.mod h1:xpPTwMhsA/aaQLAilyCCqfpEiY1gpa160AiCuWHJUjY= github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -947,6 +950,7 @@ github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/le github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -963,6 +967,7 @@ github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/term v0.0.0-20200915141129-7f0af18e79f2/go.mod h1:TjQg8pa4iejrUrjiz0MCtMV38jdMNW4doKSiBrEvCQQ= github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= @@ -1257,7 +1262,6 @@ github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518 h1:iD+PFTQwKEmbwSdwfvP5ld2WEI/g7qbdhmHJ2ASfYGs= github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518/go.mod h1:CKI4AZ4XmGV240rTHfO0hfE83S6/a3/Q1siZJ/vXf7A= diff --git a/internal/commands/build-info.go b/internal/commands/build-info.go new file mode 100644 index 00000000..d3c23c21 --- /dev/null +++ b/internal/commands/build-info.go @@ -0,0 +1,29 @@ +package commands + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" + + "github.com/authelia/authelia/internal/utils" +) + +func newBuildInfoCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "build-info", + Short: "Show the build information of Authelia", + Long: buildLong, + RunE: cmdBuildInfoRunE, + Args: cobra.NoArgs, + } + + return cmd +} + +func cmdBuildInfoRunE(_ *cobra.Command, _ []string) (err error) { + _, err = fmt.Printf(fmtAutheliaBuild, utils.BuildTag, utils.BuildState, utils.BuildBranch, utils.BuildCommit, + utils.BuildNumber, runtime.GOOS, runtime.GOARCH, utils.BuildDate, utils.BuildExtra) + + return err +} diff --git a/internal/commands/certificates.go b/internal/commands/certificates.go index 123df284..6257aae6 100644 --- a/internal/commands/certificates.go +++ b/internal/commands/certificates.go @@ -9,67 +9,192 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "fmt" "log" "math/big" "net" "os" - "path" - "strings" + "path/filepath" "time" "github.com/spf13/cobra" ) -var ( - host string - validFrom string - validFor time.Duration - isCA bool - rsaBits int - ecdsaCurve string - ed25519Key bool - certificateTargetDirectory string -) +// NewCertificatesCmd returns a new Certificates Cmd. +func NewCertificatesCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "certificates", + Short: "Commands related to certificate generation", + Args: cobra.NoArgs, + } -func init() { - CertificatesGenerateCmd.PersistentFlags().StringVar(&host, "host", "", "Comma-separated hostnames and IPs to generate a certificate for") - err := CertificatesGenerateCmd.MarkPersistentFlagRequired("host") + cmd.PersistentFlags().StringSlice("host", []string{}, "Comma-separated hostnames and IPs to generate a certificate for") + err := cmd.MarkPersistentFlagRequired("host") if err != nil { log.Fatal(err) } - CertificatesGenerateCmd.PersistentFlags().StringVar(&validFrom, "start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") - CertificatesGenerateCmd.PersistentFlags().DurationVar(&validFor, "duration", 365*24*time.Hour, "Duration that certificate is valid for") - CertificatesGenerateCmd.PersistentFlags().BoolVar(&isCA, "ca", false, "Whether this cert should be its own Certificate Authority") - CertificatesGenerateCmd.PersistentFlags().IntVar(&rsaBits, "rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set") - CertificatesGenerateCmd.PersistentFlags().StringVar(&ecdsaCurve, "ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521") - CertificatesGenerateCmd.PersistentFlags().BoolVar(&ed25519Key, "ed25519", false, "Generate an Ed25519 key") - CertificatesGenerateCmd.PersistentFlags().StringVar(&certificateTargetDirectory, "dir", "", "Target directory where the certificate and keys will be stored") + cmd.AddCommand(newCertificatesGenerateCmd()) - CertificatesCmd.AddCommand(CertificatesGenerateCmd) + return cmd } -func publicKey(priv interface{}) interface{} { - switch k := priv.(type) { - case *rsa.PrivateKey: - return &k.PublicKey - case *ecdsa.PrivateKey: - return &k.PublicKey - case ed25519.PrivateKey: - return k.Public().(ed25519.PublicKey) - default: - return nil +func newCertificatesGenerateCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "generate", + Short: "Generate a self-signed certificate", + Args: cobra.NoArgs, + Run: cmdCertificatesGenerateRun, } + + cmd.Flags().String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") + cmd.Flags().Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for") + cmd.Flags().Bool("ca", false, "Whether this cert should be its own Certificate Authority") + cmd.Flags().Int("rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set") + cmd.Flags().String("ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521") + cmd.Flags().Bool("ed25519", false, "Generate an Ed25519 key") + cmd.Flags().String("dir", "", "Target directory where the certificate and keys will be stored") + + return cmd } -//nolint:gocyclo // TODO: Consider refactoring/simplifying, time permitting. -func generateSelfSignedCertificate(cmd *cobra.Command, args []string) { +func cmdCertificatesGenerateRun(cmd *cobra.Command, _ []string) { // implementation retrieved from https://golang.org/src/crypto/tls/generate_cert.go - var priv interface{} + ecdsaCurve, err := cmd.Flags().GetString("ecdsa-curve") + if err != nil { + fmt.Printf("Failed to parse ecdsa-curve flag: %v\n", err) + os.Exit(1) + } - var err error + ed25519Key, err := cmd.Flags().GetBool("ed25519") + if err != nil { + fmt.Printf("Failed to parse ed25519 flag: %v\n", err) + os.Exit(1) + } + rsaBits, err := cmd.Flags().GetInt("rsa-bits") + if err != nil { + fmt.Printf("Failed to parse rsa-bits flag: %v\n", err) + os.Exit(1) + } + + hosts, err := cmd.Flags().GetStringSlice("host") + if err != nil { + fmt.Printf("Failed to parse host flag: %v\n", err) + os.Exit(1) + } + + validFrom, err := cmd.Flags().GetString("start-date") + if err != nil { + fmt.Printf("Failed to parse start-date flag: %v\n", err) + os.Exit(1) + } + + validFor, err := cmd.Flags().GetDuration("duration") + if err != nil { + fmt.Printf("Failed to parse duration flag: %v\n", err) + os.Exit(1) + } + + isCA, err := cmd.Flags().GetBool("ca") + if err != nil { + fmt.Printf("Failed to parse ca flag: %v\n", err) + os.Exit(1) + } + + certificateTargetDirectory, err := cmd.Flags().GetString("dir") + if err != nil { + fmt.Printf("Failed to parse dir flag: %v\n", err) + os.Exit(1) + } + + cmdCertificatesGenerateRunExtended(hosts, ecdsaCurve, validFrom, certificateTargetDirectory, ed25519Key, isCA, rsaBits, validFor) +} + +func cmdCertificatesGenerateRunExtended(hosts []string, ecdsaCurve, validFrom, certificateTargetDirectory string, ed25519Key, isCA bool, rsaBits int, validFor time.Duration) { + priv, err := getPrivateKey(ecdsaCurve, ed25519Key, rsaBits) + + if err != nil { + fmt.Printf("Failed to generate private key: %v\n", err) + os.Exit(1) + } + + var notBefore time.Time + + switch len(validFrom) { + case 0: + notBefore = time.Now() + default: + notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom) + if err != nil { + fmt.Printf("Failed to parse start date: %v\n", err) + os.Exit(1) + } + } + + notAfter := notBefore.Add(validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + fmt.Printf("Failed to generate serial number: %v\n", err) + os.Exit(1) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + if isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + certPath := filepath.Join(certificateTargetDirectory, "cert.pem") + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + if err != nil { + fmt.Printf("Failed to create certificate: %v\n", err) + os.Exit(1) + } + + writePEM(derBytes, "CERTIFICATE", certPath) + + fmt.Printf("Certificate Public Key written to %s\n", certPath) + + keyPath := filepath.Join(certificateTargetDirectory, "key.pem") + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + fmt.Printf("Failed to marshal private key: %v\n", err) + os.Exit(1) + } + + writePEM(privBytes, "PRIVATE KEY", keyPath) + + fmt.Printf("Certificate Private Key written to %s\n", keyPath) +} + +func getPrivateKey(ecdsaCurve string, ed25519Key bool, rsaBits int) (priv interface{}, err error) { switch ecdsaCurve { case "": if ed25519Key { @@ -86,114 +211,39 @@ func generateSelfSignedCertificate(cmd *cobra.Command, args []string) { case "P521": priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) default: - log.Fatalf("Unrecognized elliptic curve: %q", ecdsaCurve) + err = fmt.Errorf("unrecognized elliptic curve: %q", ecdsaCurve) } + return priv, err +} + +func writePEM(bytes []byte, blockType, path string) { + keyOut, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { - log.Fatalf("Failed to generate private key: %v", err) + fmt.Printf("Failed to open %s for writing: %v\n", path, err) + os.Exit(1) } - var notBefore time.Time - if len(validFrom) == 0 { - notBefore = time.Now() - } else { - notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom) - if err != nil { - log.Fatalf("Failed to parse creation date: %v", err) - } - } - - notAfter := notBefore.Add(validFor) - - serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - - if err != nil { - log.Fatalf("Failed to generate serial number: %v", err) - } - - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{"Acme Co"}, - }, - NotBefore: notBefore, - NotAfter: notAfter, - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - - hosts := strings.Split(host, ",") - for _, h := range hosts { - if ip := net.ParseIP(h); ip != nil { - template.IPAddresses = append(template.IPAddresses, ip) - } else { - template.DNSNames = append(template.DNSNames, h) - } - } - - if isCA { - template.IsCA = true - template.KeyUsage |= x509.KeyUsageCertSign - } - - derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) - if err != nil { - log.Fatalf("Failed to create certificate: %v", err) - } - - certPath := path.Join(certificateTargetDirectory, "cert.pem") - certOut, err := os.Create(certPath) - - if err != nil { - log.Fatalf("Failed to open %s for writing: %v", certPath, err) - } - - if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { - log.Fatalf("Failed to write data to cert.pem: %v", err) - } - - if err := certOut.Close(); err != nil { - log.Fatalf("Error closing %s: %v", certPath, err) - } - - log.Printf("wrote %s\n", certPath) - - keyPath := path.Join(certificateTargetDirectory, "key.pem") - keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - - if err != nil { - log.Fatalf("Failed to open %s for writing: %v", keyPath, err) - return - } - - privBytes, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - log.Fatalf("Unable to marshal private key: %v", err) - } - - if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { - log.Fatalf("Failed to write data to %s: %v", keyPath, err) + if err := pem.Encode(keyOut, &pem.Block{Type: blockType, Bytes: bytes}); err != nil { + fmt.Printf("Failed to write data to %s: %v\n", path, err) + os.Exit(1) } if err := keyOut.Close(); err != nil { - log.Fatalf("Error closing %s: %v", keyPath, err) + fmt.Printf("Error closing %s: %v\n", path, err) + os.Exit(1) } - - log.Printf("wrote %s\n", keyPath) } -// CertificatesCmd certificate helper command. -var CertificatesCmd = &cobra.Command{ - Use: "certificates", - Short: "Commands related to certificate generation", -} - -// CertificatesGenerateCmd certificate generation command. -var CertificatesGenerateCmd = &cobra.Command{ - Use: "generate", - Short: "Generate a self-signed certificate", - Run: generateSelfSignedCertificate, +func publicKey(priv interface{}) interface{} { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + case ed25519.PrivateKey: + return k.Public().(ed25519.PublicKey) + default: + return nil + } } diff --git a/internal/commands/completion.go b/internal/commands/completion.go new file mode 100644 index 00000000..fa19e35c --- /dev/null +++ b/internal/commands/completion.go @@ -0,0 +1,45 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newCompletionCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate completion script", + Long: completionLong, + Args: cobra.ExactValidArgs(1), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + DisableFlagsInUseLine: true, + Run: cmdCompletionRun, + } + + return cmd +} + +func cmdCompletionRun(cmd *cobra.Command, args []string) { + var err error + + switch args[0] { + case "bash": + err = cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + err = cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + err = cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + err = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + default: + fmt.Printf("Invalid shell provided for completion command: %s\n", args[0]) + os.Exit(1) + } + + if err != nil { + fmt.Printf("Error generating completion: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/commands/configuration.go b/internal/commands/configuration.go new file mode 100644 index 00000000..480d25e9 --- /dev/null +++ b/internal/commands/configuration.go @@ -0,0 +1,75 @@ +package commands + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/authelia/authelia/internal/configuration" + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/configuration/validator" + "github.com/authelia/authelia/internal/logging" +) + +// cmdWithConfigFlags is used for commands which require access to the configuration to add the flag to the command. +func cmdWithConfigFlags(cmd *cobra.Command) { + cmd.Flags().StringSliceP("config", "c", []string{}, "Configuration files") +} + +var config *schema.Configuration + +func newCmdWithConfigPreRun(ensureConfigExists, validateKeys, validateConfiguration bool) func(cmd *cobra.Command, args []string) { + return func(cmd *cobra.Command, _ []string) { + logger := logging.Logger() + + configs, err := cmd.Root().Flags().GetStringSlice("config") + if err != nil { + logger.Fatalf("Error reading flags: %v", err) + } + + if ensureConfigExists && len(configs) == 1 { + created, err := configuration.EnsureConfigurationExists(configs[0]) + if err != nil { + logger.Fatal(err) + } + + if created { + logger.Warnf("Configuration did not exist so a default one has been generated at %s, you will need to configure this", configs[0]) + os.Exit(0) + } + } + + var keys []string + + val := schema.NewStructValidator() + + keys, config, err = configuration.Load(val, configuration.NewDefaultSources(configs, configuration.DefaultEnvPrefix, configuration.DefaultEnvDelimiter)...) + if err != nil { + logger.Fatalf("Error occurred loading configuration: %v", err) + } + + if validateKeys { + validator.ValidateKeys(keys, configuration.DefaultEnvPrefix, val) + } + + if validateConfiguration { + validator.ValidateConfiguration(config, val) + } + + warnings := val.Warnings() + if len(warnings) != 0 { + for _, warning := range warnings { + logger.Warnf("Configuration: %+v", warning) + } + } + + errs := val.Errors() + if len(errs) != 0 { + for _, err := range errs { + logger.Errorf("Configuration: %+v", err) + } + + logger.Fatalf("Can't continue due to the errors loading the configuration") + } + } +} diff --git a/internal/commands/const.go b/internal/commands/const.go new file mode 100644 index 00000000..a1373ae1 --- /dev/null +++ b/internal/commands/const.go @@ -0,0 +1,77 @@ +package commands + +const cmdAutheliaExample = `authelia --config /etc/authelia/config.yml --config /etc/authelia/access-control.yml +authelia --config /etc/authelia/config.yml,/etc/authelia/access-control.yml +authelia --config /etc/authelia/config/ +` + +const fmtAutheliaLong = `authelia %s + +An open-source authentication and authorization server providing +two-factor authentication and single sign-on (SSO) for your +applications via a web portal. + +Documentation is available at: https://www.authelia.com/docs +` + +const fmtAutheliaBuild = `Last Tag: %s +State: %s +Branch: %s +Commit: %s +Build Number: %s +Build OS: %s +Build Arch: %s +Build Date: %s +Extra: %s +` + +const buildLong = `Show the build information of Authelia + +This outputs detailed version information about the specific version +of the Authelia binary. This information is embedded into Authelia +by the continuous integration. + +This could be vital in debugging if you're not using a particular +tagged build of Authelia. It's suggested to provide it along with +your issue. +` + +const completionLong = `To load completions: + +Bash: + + $ source <(authelia completion bash) + + # To load completions for each session, execute once: + # Linux: + $ authelia completion bash > /etc/bash_completion.d/authelia + # macOS: + $ authelia completion bash > /usr/local/etc/bash_completion.d/authelia + +Zsh: + + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ authelia completion zsh > "${fpath[1]}/_authelia" + + # You will need to start a new shell for this setup to take effect. + +fish: + + $ authelia completion fish | source + + # To load completions for each session, execute once: + $ authelia completion fish > ~/.config/fish/completions/authelia.fish + +PowerShell: + + PS> authelia completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + PS> authelia completion powershell > authelia.ps1 + # and source this file from your PowerShell profile. +` diff --git a/internal/commands/hash.go b/internal/commands/hash.go index cde188cb..1d35c56e 100644 --- a/internal/commands/hash.go +++ b/internal/commands/hash.go @@ -2,60 +2,67 @@ package commands import ( "fmt" - "log" "github.com/simia-tech/crypt" "github.com/spf13/cobra" "github.com/authelia/authelia/internal/authentication" "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/logging" ) -func init() { - HashPasswordCmd.Flags().BoolP("sha512", "z", false, fmt.Sprintf("use sha512 as the algorithm (changes iterations to %d, change with -i)", schema.DefaultPasswordSHA512Configuration.Iterations)) - HashPasswordCmd.Flags().IntP("iterations", "i", schema.DefaultPasswordConfiguration.Iterations, "set the number of hashing iterations") - HashPasswordCmd.Flags().StringP("salt", "s", "", "set the salt string") - HashPasswordCmd.Flags().IntP("memory", "m", schema.DefaultPasswordConfiguration.Memory, "[argon2id] set the amount of memory param (in MB)") - HashPasswordCmd.Flags().IntP("parallelism", "p", schema.DefaultPasswordConfiguration.Parallelism, "[argon2id] set the parallelism param") - HashPasswordCmd.Flags().IntP("key-length", "k", schema.DefaultPasswordConfiguration.KeyLength, "[argon2id] set the key length param") - HashPasswordCmd.Flags().IntP("salt-length", "l", schema.DefaultPasswordConfiguration.SaltLength, "set the auto-generated salt length") +// NewHashPasswordCmd returns a new Hash Password Cmd. +func NewHashPasswordCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "hash-password [password]", + Short: "Hash a password to be used in file-based users database. Default algorithm is argon2id.", + Args: cobra.MinimumNArgs(1), + Run: cmdHashPasswordRun, + } + + cmd.Flags().BoolP("sha512", "z", false, fmt.Sprintf("use sha512 as the algorithm (changes iterations to %d, change with -i)", schema.DefaultPasswordSHA512Configuration.Iterations)) + cmd.Flags().IntP("iterations", "i", schema.DefaultPasswordConfiguration.Iterations, "set the number of hashing iterations") + cmd.Flags().StringP("salt", "s", "", "set the salt string") + cmd.Flags().IntP("memory", "m", schema.DefaultPasswordConfiguration.Memory, "[argon2id] set the amount of memory param (in MB)") + cmd.Flags().IntP("parallelism", "p", schema.DefaultPasswordConfiguration.Parallelism, "[argon2id] set the parallelism param") + cmd.Flags().IntP("key-length", "k", schema.DefaultPasswordConfiguration.KeyLength, "[argon2id] set the key length param") + cmd.Flags().IntP("salt-length", "l", schema.DefaultPasswordConfiguration.SaltLength, "set the auto-generated salt length") + + return cmd } -// HashPasswordCmd password hashing command. -var HashPasswordCmd = &cobra.Command{ - Use: "hash-password [password]", - Short: "Hash a password to be used in file-based users database. Default algorithm is argon2id.", - Run: func(cobraCmd *cobra.Command, args []string) { - sha512, _ := cobraCmd.Flags().GetBool("sha512") - iterations, _ := cobraCmd.Flags().GetInt("iterations") - salt, _ := cobraCmd.Flags().GetString("salt") - keyLength, _ := cobraCmd.Flags().GetInt("key-length") - saltLength, _ := cobraCmd.Flags().GetInt("salt-length") - memory, _ := cobraCmd.Flags().GetInt("memory") - parallelism, _ := cobraCmd.Flags().GetInt("parallelism") +func cmdHashPasswordRun(cmd *cobra.Command, args []string) { + sha512, _ := cmd.Flags().GetBool("sha512") + iterations, _ := cmd.Flags().GetInt("iterations") + salt, _ := cmd.Flags().GetString("salt") + keyLength, _ := cmd.Flags().GetInt("key-length") + saltLength, _ := cmd.Flags().GetInt("salt-length") + memory, _ := cmd.Flags().GetInt("memory") + parallelism, _ := cmd.Flags().GetInt("parallelism") - var err error - var hash string - var algorithm authentication.CryptAlgo + var ( + hash string + algorithm authentication.CryptAlgo + ) - if sha512 { - if iterations == schema.DefaultPasswordConfiguration.Iterations { - iterations = schema.DefaultPasswordSHA512Configuration.Iterations - } - algorithm = authentication.HashingAlgorithmSHA512 - } else { - algorithm = authentication.HashingAlgorithmArgon2id - } - if salt != "" { - salt = crypt.Base64Encoding.EncodeToString([]byte(salt)) + if sha512 { + if iterations == schema.DefaultPasswordConfiguration.Iterations { + iterations = schema.DefaultPasswordSHA512Configuration.Iterations } - hash, err = authentication.HashPassword(args[0], salt, algorithm, iterations, memory*1024, parallelism, keyLength, saltLength) - if err != nil { - log.Fatalf("Error occurred during hashing: %s\n", err) - } else { - fmt.Printf("Password hash: %s\n", hash) - } - }, - Args: cobra.MinimumNArgs(1), + algorithm = authentication.HashingAlgorithmSHA512 + } else { + algorithm = authentication.HashingAlgorithmArgon2id + } + + if salt != "" { + salt = crypt.Base64Encoding.EncodeToString([]byte(salt)) + } + + hash, err := authentication.HashPassword(args[0], salt, algorithm, iterations, memory*1024, parallelism, keyLength, saltLength) + if err != nil { + logging.Logger().Fatalf("Error occurred during hashing: %v\n", err) + } + + fmt.Printf("Password hash: %s\n", hash) } diff --git a/internal/commands/root.go b/internal/commands/root.go new file mode 100644 index 00000000..369e92a8 --- /dev/null +++ b/internal/commands/root.go @@ -0,0 +1,155 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/authelia/authelia/internal/authentication" + "github.com/authelia/authelia/internal/authorization" + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/logging" + "github.com/authelia/authelia/internal/middlewares" + "github.com/authelia/authelia/internal/notification" + "github.com/authelia/authelia/internal/oidc" + "github.com/authelia/authelia/internal/regulation" + "github.com/authelia/authelia/internal/server" + "github.com/authelia/authelia/internal/session" + "github.com/authelia/authelia/internal/storage" + "github.com/authelia/authelia/internal/utils" +) + +// NewRootCmd returns a new Root Cmd. +func NewRootCmd() (cmd *cobra.Command) { + version := utils.Version() + + cmd = &cobra.Command{ + Use: "authelia", + Example: cmdAutheliaExample, + Short: fmt.Sprintf("authelia %s", version), + Long: fmt.Sprintf(fmtAutheliaLong, version), + Version: version, + Args: cobra.NoArgs, + PreRun: newCmdWithConfigPreRun(true, true, true), + Run: cmdRootRun, + } + + cmdWithConfigFlags(cmd) + + cmd.AddCommand( + newBuildInfoCmd(), + NewCertificatesCmd(), + newCompletionCmd(), + NewHashPasswordCmd(), + NewRSACmd(), + newValidateConfigCmd(), + ) + + return cmd +} + +func cmdRootRun(_ *cobra.Command, _ []string) { + logger := logging.Logger() + + logger.Infof("Authelia %s is starting", utils.Version()) + + if os.Getenv("ENVIRONMENT") == "dev" { + logger.Info("===> Authelia is running in development mode. <===") + } + + if err := logging.InitializeLogger(config.Log, true); err != nil { + logger.Fatalf("Cannot initialize logger: %v", err) + } + + providers, warnings, errors := getProviders(config) + if len(warnings) != 0 { + for _, err := range warnings { + logger.Warn(err) + } + } + + if len(errors) != 0 { + for _, err := range errors { + logger.Error(err) + } + + logger.Fatalf("Errors occurred provisioning providers.") + } + + server.Start(*config, providers) +} + +func getProviders(config *schema.Configuration) (providers middlewares.Providers, warnings []error, errors []error) { + autheliaCertPool, warnings, errors := utils.NewX509CertPool(config.CertificatesDirectory) + if len(warnings) != 0 || len(errors) != 0 { + return providers, warnings, errors + } + + var storageProvider storage.Provider + + switch { + case config.Storage.PostgreSQL != nil: + storageProvider = storage.NewPostgreSQLProvider(*config.Storage.PostgreSQL) + case config.Storage.MySQL != nil: + storageProvider = storage.NewMySQLProvider(*config.Storage.MySQL) + case config.Storage.Local != nil: + storageProvider = storage.NewSQLiteProvider(config.Storage.Local.Path) + default: + errors = append(errors, fmt.Errorf("unrecognized storage provider")) + } + + var ( + userProvider authentication.UserProvider + err error + ) + + switch { + case config.AuthenticationBackend.File != nil: + userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) + case config.AuthenticationBackend.LDAP != nil: + userProvider, err = authentication.NewLDAPUserProvider(config.AuthenticationBackend, autheliaCertPool) + if err != nil { + errors = append(errors, fmt.Errorf("failed to check LDAP authentication backend: %w", err)) + } + default: + errors = append(errors, fmt.Errorf("unrecognized user provider")) + } + + var notifier notification.Notifier + + switch { + case config.Notifier.SMTP != nil: + notifier = notification.NewSMTPNotifier(*config.Notifier.SMTP, autheliaCertPool) + case config.Notifier.FileSystem != nil: + notifier = notification.NewFileNotifier(*config.Notifier.FileSystem) + default: + errors = append(errors, fmt.Errorf("unrecognized notifier provider")) + } + + if notifier != nil { + if _, err := notifier.StartupCheck(); err != nil { + errors = append(errors, fmt.Errorf("failed to check notification provider: %w", err)) + } + } + + clock := utils.RealClock{} + authorizer := authorization.NewAuthorizer(config) + sessionProvider := session.NewProvider(config.Session, autheliaCertPool) + regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock) + + oidcProvider, err := oidc.NewOpenIDConnectProvider(config.IdentityProviders.OIDC) + if err != nil { + errors = append(errors, err) + } + + return middlewares.Providers{ + Authorizer: authorizer, + UserProvider: userProvider, + Regulator: regulator, + OpenIDConnect: oidcProvider, + StorageProvider: storageProvider, + Notifier: notifier, + SessionProvider: sessionProvider, + }, warnings, errors +} diff --git a/internal/commands/rsa.go b/internal/commands/rsa.go index a0d5d68f..29e995dd 100644 --- a/internal/commands/rsa.go +++ b/internal/commands/rsa.go @@ -1,79 +1,106 @@ package commands import ( - "log" + "fmt" "os" - "path" + "path/filepath" "github.com/spf13/cobra" "github.com/authelia/authelia/internal/utils" ) -var rsaTargetDirectory string +// NewRSACmd returns a new RSA Cmd. +func NewRSACmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "rsa", + Short: "Commands related to rsa keypair generation", + Args: cobra.NoArgs, + } -func init() { - RSAGenerateCmd.PersistentFlags().StringVar(&rsaTargetDirectory, "dir", "", "Target directory where the keypair will be stored") + cmd.AddCommand(newRSAGenerateCmd()) - RSACmd.AddCommand(RSAGenerateCmd) + return cmd } -func generateRSAKeypair(cmd *cobra.Command, args []string) { - privateKey, publicKey := utils.GenerateRsaKeyPair(2048) +func newRSAGenerateCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "generate", + Short: "Generate a RSA keypair", + Args: cobra.NoArgs, + Run: cmdRSAGenerateRun, + } - keyPath := path.Join(rsaTargetDirectory, "key.pem") - keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + cmd.Flags().StringP("dir", "d", "", "Target directory where the keypair will be stored") + cmd.Flags().IntP("key-size", "b", 2048, "Sets the key size in bits") + return cmd +} + +func cmdRSAGenerateRun(cmd *cobra.Command, _ []string) { + bits, err := cmd.Flags().GetInt("key-size") if err != nil { - log.Fatalf("Failed to open %s for writing: %v", keyPath, err) + fmt.Printf("Failed to parse key-size flag: %v\n", err) return } + privateKey, publicKey := utils.GenerateRsaKeyPair(bits) + + rsaTargetDirectory, err := cmd.Flags().GetString("dir") + if err != nil { + fmt.Printf("Failed to parse dir flag: %v\n", err) + return + } + + keyPath := filepath.Join(rsaTargetDirectory, "key.pem") + + keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + fmt.Printf("Failed to open %s for writing: %v\n", keyPath, err) + return + } + + defer func() { + if err := keyOut.Close(); err != nil { + fmt.Printf("Unable to close private key file: %v\n", err) + os.Exit(1) + } + }() + _, err = keyOut.WriteString(utils.ExportRsaPrivateKeyAsPemStr(privateKey)) if err != nil { - log.Fatalf("Unable to write private key: %v", err) + fmt.Printf("Failed to write private key: %v\n", err) return } - if err := keyOut.Close(); err != nil { - log.Fatalf("Unable to close private key file: %v", err) - return - } + fmt.Printf("RSA Private Key written to %s\n", keyPath) - keyPath = path.Join(rsaTargetDirectory, "key.pub") - keyOut, err = os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + certPath := filepath.Join(rsaTargetDirectory, "key.pub") + certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { - log.Fatalf("Failed to open %s for writing: %v", keyPath, err) + fmt.Printf("Failed to open %s for writing: %v\n", keyPath, err) return } + defer func() { + if err := certOut.Close(); err != nil { + fmt.Printf("Failed to close public key file: %v\n", err) + os.Exit(1) + } + }() + publicPem, err := utils.ExportRsaPublicKeyAsPemStr(publicKey) if err != nil { - log.Fatalf("Unable to marshal public key: %v", err) + fmt.Printf("Failed to marshal public key: %v\n", err) + return } - _, err = keyOut.WriteString(publicPem) + _, err = certOut.WriteString(publicPem) if err != nil { - log.Fatalf("Unable to write private key: %v", err) + fmt.Printf("Failed to write private key: %v\n", err) return } - if err := keyOut.Close(); err != nil { - log.Fatalf("Unable to close public key file: %v", err) - return - } -} - -// RSACmd RSA helper command. -var RSACmd = &cobra.Command{ - Use: "rsa", - Short: "Commands related to rsa keypair generation", -} - -// RSAGenerateCmd certificate generation command. -var RSAGenerateCmd = &cobra.Command{ - Use: "generate", - Short: "Generate a RSA keypair", - Run: generateRSAKeypair, + fmt.Printf("RSA Public Key written to %s\n", certPath) } diff --git a/internal/commands/validate.go b/internal/commands/validate.go index 73a9c65b..54f119a9 100644 --- a/internal/commands/validate.go +++ b/internal/commands/validate.go @@ -1,40 +1,66 @@ package commands import ( - "fmt" "log" "os" "github.com/spf13/cobra" "github.com/authelia/authelia/internal/configuration" + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/configuration/validator" + "github.com/authelia/authelia/internal/logging" ) -// 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) +func newValidateConfigCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "validate-config [yaml]", + Short: "Check a configuration against the internal configuration validation mechanisms", + Args: cobra.MinimumNArgs(1), + Run: cmdValidateConfigRun, + } + + return cmd +} + +func cmdValidateConfigRun(_ *cobra.Command, args []string) { + logger := logging.Logger() + + configPath := args[0] + if _, err := os.Stat(configPath); err != nil { + logger.Fatalf("Error Loading Configuration: %v\n", err) + } + + val := schema.NewStructValidator() + + keys, conf, err := configuration.Load(val, configuration.NewYAMLFileSource(configPath)) + if err != nil { + logger.Fatalf("Error occurred loading configuration: %v", err) + } + + validator.ValidateKeys(keys, configuration.DefaultEnvPrefix, val) + validator.ValidateConfiguration(conf, val) + + warnings := val.Warnings() + errors := val.Errors() + + if len(warnings) != 0 { + logger.Warn("Warnings occurred while loading the configuration:") + + for _, warn := range warnings { + logger.Warnf(" %+v", warn) + } + } + + if len(errors) != 0 { + logger.Error("Errors occurred while loading the configuration:") + + for _, err := range errors { + logger.Errorf(" %+v", 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), + logger.Fatal("Can't continue due to errors") + } + + log.Println("Configuration parsed successfully without errors.") } diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index d8d877f8..c552a45a 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -29,21 +29,16 @@ default_redirection_url: https://home.example.com/ ## Server Configuration ## server: + ## The address to listen on. host: 0.0.0.0 ## The port to listen on. port: 9091 - ## Authelia by default doesn't accept TLS communication on the server port. This section overrides this behaviour. - tls: - ## The path to the DER base64/PEM format private key. - key: "" - # key: /config/ssl/key.pem - - ## The path to the DER base64/PEM format public certificate. - certificate: "" - # certificate: /config/ssl/cert.pem + ## Set the single level path Authelia listens on. + ## Must be alphanumeric chars and should not contain any slashes. + path: "" ## Buffers usually should be configured to be the same value. ## Explanation at https://www.authelia.com/docs/configuration/server.html @@ -52,16 +47,23 @@ server: read_buffer_size: 4096 write_buffer_size: 4096 - ## Set the single level path Authelia listens on. - ## Must be alphanumeric chars and should not contain any slashes. - path: "" - ## Enables the pprof endpoint. enable_pprof: false ## Enables the expvars endpoint. enable_expvars: false + ## Authelia by default doesn't accept TLS communication on the server port. This section overrides this behaviour. + tls: + ## The path to the DER base64/PEM format private key. + key: "" + + ## The path to the DER base64/PEM format public certificate. + certificate: "" + +## +## Log Configuration +## log: ## Level of verbosity for logs: info, debug, trace. level: debug diff --git a/internal/configuration/configuration_test.go b/internal/configuration/configuration_test.go new file mode 100644 index 00000000..263dd46f --- /dev/null +++ b/internal/configuration/configuration_test.go @@ -0,0 +1,19 @@ +package configuration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authelia/authelia/internal/utils" +) + +func TestShouldHaveSameChecksumForBothTemplates(t *testing.T) { + sumRoot, err := utils.HashSHA256FromPath("../../config.template.yml") + assert.NoError(t, err) + + sumInternal, err := utils.HashSHA256FromPath("./config.template.yml") + assert.NoError(t, err) + + assert.Equal(t, sumRoot, sumInternal, "Ensure both ./config.template.yml and ./internal/configuration/config.template.yml are exactly the same.") +} diff --git a/internal/configuration/const.go b/internal/configuration/const.go index 961c210b..e54bcfcb 100644 --- a/internal/configuration/const.go +++ b/internal/configuration/const.go @@ -1,3 +1,30 @@ package configuration -const windows = "windows" +import ( + "errors" +) + +// DefaultEnvPrefix is the default environment prefix. +const DefaultEnvPrefix = "AUTHELIA_" + +// DefaultEnvDelimiter is the default environment delimiter. +const DefaultEnvDelimiter = "_" + +const ( + constSecretSuffix = "_FILE" + + constDelimiter = "." + + constWindows = "windows" +) + +const ( + errFmtSecretAlreadyDefined = "secrets: error loading secret into key '%s': it's already defined in other " + + "configuration sources" + errFmtSecretIOIssue = "secrets: error loading secret path %s into key '%s': %v" + errFmtGenerateConfiguration = "error occurred generating configuration: %+v" +) + +var secretSuffixes = []string{"key", "secret", "password", "token"} +var errNoSources = errors.New("no sources provided") +var errNoValidator = errors.New("no validator provided") diff --git a/internal/configuration/helpers.go b/internal/configuration/helpers.go new file mode 100644 index 00000000..6301e4dd --- /dev/null +++ b/internal/configuration/helpers.go @@ -0,0 +1,55 @@ +package configuration + +import ( + "io/ioutil" + "strings" + + "github.com/authelia/authelia/internal/utils" +) + +func getEnvConfigMap(keys []string, prefix, delimiter string) (keyMap map[string]string, ignoredKeys []string) { + keyMap = make(map[string]string) + + for _, key := range keys { + if strings.Contains(key, delimiter) { + originalKey := prefix + strings.ToUpper(strings.ReplaceAll(key, constDelimiter, delimiter)) + keyMap[originalKey] = key + } + + // Secret envs should be ignored by the env parser. + if isSecretKey(key) { + originalKey := strings.ToUpper(strings.ReplaceAll(key, constDelimiter, delimiter)) + constSecretSuffix + + ignoredKeys = append(ignoredKeys, prefix+originalKey) + } + } + + return keyMap, ignoredKeys +} + +func getSecretConfigMap(keys []string, prefix, delimiter string) (keyMap map[string]string) { + keyMap = make(map[string]string) + + for _, key := range keys { + if isSecretKey(key) { + originalKey := strings.ToUpper(strings.ReplaceAll(key, constDelimiter, delimiter)) + constSecretSuffix + + keyMap[prefix+originalKey] = key + } + } + + return keyMap +} + +func isSecretKey(key string) (isSecretKey bool) { + return utils.IsStringInSliceSuffix(key, secretSuffixes) +} + +func loadSecret(path string) (value string, err error) { + content, err := ioutil.ReadFile(path) + if err != nil { + return "", err + } + + return strings.TrimRight(string(content), "\n"), err +} diff --git a/internal/configuration/helpers_test.go b/internal/configuration/helpers_test.go new file mode 100644 index 00000000..b244d4dc --- /dev/null +++ b/internal/configuration/helpers_test.go @@ -0,0 +1,85 @@ +package configuration + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSecretKey(t *testing.T) { + assert.True(t, isSecretKey("my_fake_token")) + assert.False(t, isSecretKey("my_fake_tokenz")) + assert.True(t, isSecretKey("my_.fake.secret")) + assert.True(t, isSecretKey("my.password")) + assert.False(t, isSecretKey("my.passwords")) + assert.False(t, isSecretKey("my.passwords")) +} + +func TestGetEnvConfigMaps(t *testing.T) { + var ( + key string + ok bool + ) + + input := []string{ + "my.non_secret.config_item", + "myother.configkey", + "mysecret.password", + "mysecret.user_password", + } + + keys, ignoredKeys := getEnvConfigMap(input, DefaultEnvPrefix, DefaultEnvDelimiter) + + key, ok = keys[DefaultEnvPrefix+"MY_NON_SECRET_CONFIG_ITEM"] + assert.True(t, ok) + assert.Equal(t, key, "my.non_secret.config_item") + + key, ok = keys[DefaultEnvPrefix+"MYSECRET_USER_PASSWORD"] + assert.True(t, ok) + assert.Equal(t, key, "mysecret.user_password") + + key, ok = keys[DefaultEnvPrefix+"MYOTHER_CONFIGKEY"] + assert.False(t, ok) + assert.Equal(t, key, "") + + key, ok = keys[DefaultEnvPrefix+"MYSECRET_PASSWORD"] + assert.False(t, ok) + assert.Equal(t, key, "") + + assert.Len(t, ignoredKeys, 3) + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"MYOTHER_CONFIGKEY_FILE") + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"MYSECRET_PASSWORD_FILE") + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"MYSECRET_USER_PASSWORD_FILE") +} + +func TestGetSecretConfigMap(t *testing.T) { + var ( + key string + ok bool + ) + + input := []string{ + "my.non_secret.config_item", + "myother.configkey", + "mysecret.password", + "mysecret.user_password", + } + + keys := getSecretConfigMap(input, DefaultEnvPrefix, DefaultEnvDelimiter) + + key, ok = keys[DefaultEnvPrefix+"MY_NON_SECRET_CONFIG_ITEM_FILE"] + assert.False(t, ok) + assert.Equal(t, key, "") + + key, ok = keys[DefaultEnvPrefix+"MYOTHER_CONFIGKEY_FILE"] + assert.True(t, ok) + assert.Equal(t, key, "myother.configkey") + + key, ok = keys[DefaultEnvPrefix+"MYSECRET_PASSWORD_FILE"] + assert.True(t, ok) + assert.Equal(t, key, "mysecret.password") + + key, ok = keys[DefaultEnvPrefix+"MYSECRET_USER_PASSWORD_FILE"] + assert.True(t, ok) + assert.Equal(t, key, "mysecret.user_password") +} diff --git a/internal/configuration/koanf_callbacks.go b/internal/configuration/koanf_callbacks.go new file mode 100644 index 00000000..87a69254 --- /dev/null +++ b/internal/configuration/koanf_callbacks.go @@ -0,0 +1,50 @@ +package configuration + +import ( + "fmt" + "strings" + + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/configuration/validator" + "github.com/authelia/authelia/internal/utils" +) + +// koanfEnvironmentCallback returns a koanf callback to map the environment vars to Configuration keys. +func koanfEnvironmentCallback(keyMap map[string]string, ignoredKeys []string, prefix, delimiter string) func(key, value string) (finalKey string, finalValue interface{}) { + return func(key, value string) (finalKey string, finalValue interface{}) { + if k, ok := keyMap[key]; ok { + return k, value + } + + if utils.IsStringInSlice(key, ignoredKeys) { + return "", nil + } + + formattedKey := strings.TrimPrefix(key, prefix) + formattedKey = strings.ReplaceAll(strings.ToLower(formattedKey), delimiter, constDelimiter) + + if utils.IsStringInSlice(formattedKey, validator.ValidKeys) { + return formattedKey, value + } + + return key, value + } +} + +// koanfEnvironmentSecretsCallback returns a koanf callback to map the environment vars to Configuration keys. +func koanfEnvironmentSecretsCallback(keyMap map[string]string, validator *schema.StructValidator) func(key, value string) (finalKey string, finalValue interface{}) { + return func(key, value string) (finalKey string, finalValue interface{}) { + k, ok := keyMap[key] + if !ok { + return "", nil + } + + v, err := loadSecret(value) + if err != nil { + validator.Push(fmt.Errorf(errFmtSecretIOIssue, value, k, err)) + return k, "" + } + + return k, v + } +} diff --git a/internal/configuration/koanf_callbacks_test.go b/internal/configuration/koanf_callbacks_test.go new file mode 100644 index 00000000..51a1f3ce --- /dev/null +++ b/internal/configuration/koanf_callbacks_test.go @@ -0,0 +1,129 @@ +package configuration + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authelia/authelia/internal/configuration/schema" +) + +func TestKoanfEnvironmentCallback(t *testing.T) { + var ( + key string + value interface{} + ) + + keyMap := map[string]string{ + DefaultEnvPrefix + "KEY_EXAMPLE_UNDERSCORE": "key.example_underscore", + } + ignoredKeys := []string{DefaultEnvPrefix + "SOME_SECRET"} + + callback := koanfEnvironmentCallback(keyMap, ignoredKeys, DefaultEnvPrefix, DefaultEnvDelimiter) + + key, value = callback(DefaultEnvPrefix+"KEY_EXAMPLE_UNDERSCORE", "value") + assert.Equal(t, "key.example_underscore", key) + assert.Equal(t, "value", value) + + key, value = callback(DefaultEnvPrefix+"KEY_EXAMPLE", "value") + assert.Equal(t, DefaultEnvPrefix+"KEY_EXAMPLE", key) + assert.Equal(t, "value", value) + + key, value = callback(DefaultEnvPrefix+"THEME", "value") + assert.Equal(t, "theme", key) + assert.Equal(t, "value", value) + + key, value = callback(DefaultEnvPrefix+"SOME_SECRET", "value") + assert.Equal(t, "", key) + assert.Nil(t, value) +} + +func TestKoanfSecretCallbackWithValidSecrets(t *testing.T) { + var ( + key string + value interface{} + ) + + keyMap := map[string]string{ + "AUTHELIA__JWT_SECRET": "jwt_secret", + "AUTHELIA_JWT_SECRET": "jwt_secret", + "AUTHELIA_FAKE_KEY": "fake_key", + "AUTHELIA__FAKE_KEY": "fake_key", + "AUTHELIA_STORAGE_MYSQL_FAKE_PASSWORD": "storage.mysql.fake_password", + "AUTHELIA__STORAGE_MYSQL_FAKE_PASSWORD": "storage.mysql.fake_password", + } + + dir, err := ioutil.TempDir("", "authelia-test-callbacks") + assert.NoError(t, err) + + secretOne := filepath.Join(dir, "secert_one") + secretTwo := filepath.Join(dir, "secret_two") + + assert.NoError(t, testCreateFile(secretOne, "value one", 0600)) + assert.NoError(t, testCreateFile(secretTwo, "value two", 0600)) + + val := schema.NewStructValidator() + + callback := koanfEnvironmentSecretsCallback(keyMap, val) + + key, value = callback("AUTHELIA_FAKE_KEY", secretOne) + assert.Equal(t, "fake_key", key) + assert.Equal(t, "value one", value) + + key, value = callback("AUTHELIA__STORAGE_MYSQL_FAKE_PASSWORD", secretTwo) + assert.Equal(t, "storage.mysql.fake_password", key) + assert.Equal(t, "value two", value) +} + +func TestKoanfSecretCallbackShouldIgnoreUndetectedSecrets(t *testing.T) { + keyMap := map[string]string{ + "AUTHELIA__JWT_SECRET": "jwt_secret", + "AUTHELIA_JWT_SECRET": "jwt_secret", + } + + val := schema.NewStructValidator() + + callback := koanfEnvironmentSecretsCallback(keyMap, val) + + key, value := callback("AUTHELIA__SESSION_DOMAIN", "/tmp/not-a-path") + assert.Equal(t, "", key) + assert.Nil(t, value) + + assert.Len(t, val.Errors(), 0) + assert.Len(t, val.Warnings(), 0) +} + +func TestKoanfSecretCallbackShouldErrorOnFSError(t *testing.T) { + if runtime.GOOS == constWindows { + t.Skip("skipping test due to being on windows") + } + + keyMap := map[string]string{ + "AUTHELIA__THEME": "theme", + "AUTHELIA_THEME": "theme", + } + + dir, err := ioutil.TempDir("", "authelia-test-callbacks") + assert.NoError(t, err) + + secret := filepath.Join(dir, "inaccessible") + + assert.NoError(t, testCreateFile(secret, "secret", 0000)) + + val := schema.NewStructValidator() + + callback := koanfEnvironmentSecretsCallback(keyMap, val) + + key, value := callback("AUTHELIA_THEME", secret) + assert.Equal(t, "theme", key) + assert.Equal(t, "", value) + + require.Len(t, val.Errors(), 1) + assert.Len(t, val.Warnings(), 0) + assert.EqualError(t, val.Errors()[0], fmt.Sprintf(errFmtSecretIOIssue, secret, "theme", fmt.Sprintf("open %s: permission denied", secret))) +} diff --git a/internal/configuration/provider.go b/internal/configuration/provider.go new file mode 100644 index 00000000..0f6ae8bd --- /dev/null +++ b/internal/configuration/provider.go @@ -0,0 +1,75 @@ +package configuration + +import ( + "fmt" + + "github.com/knadh/koanf" + "github.com/mitchellh/mapstructure" + + "github.com/authelia/authelia/internal/configuration/schema" +) + +// Load the configuration given the provided options and sources. +func Load(val *schema.StructValidator, sources ...Source) (keys []string, configuration *schema.Configuration, err error) { + if val == nil { + return keys, configuration, errNoValidator + } + + ko := koanf.NewWithConf(koanf.Conf{ + Delim: constDelimiter, + StrictMerge: false, + }) + + err = loadSources(ko, val, sources...) + if err != nil { + return ko.Keys(), configuration, err + } + + configuration = &schema.Configuration{} + + unmarshal(ko, val, "", configuration) + + return ko.Keys(), configuration, nil +} + +func unmarshal(ko *koanf.Koanf, val *schema.StructValidator, path string, o interface{}) { + c := koanf.UnmarshalConf{ + DecoderConfig: &mapstructure.DecoderConfig{ + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + Metadata: nil, + Result: o, + WeaklyTypedInput: true, + }, + } + + if err := ko.UnmarshalWithConf(path, o, c); err != nil { + val.Push(fmt.Errorf("error occurred during unmarshalling configuration: %w", err)) + } +} + +func loadSources(ko *koanf.Koanf, val *schema.StructValidator, sources ...Source) (err error) { + if len(sources) == 0 { + return errNoSources + } + + for _, source := range sources { + err := source.Load(val) + if err != nil { + val.Push(fmt.Errorf("failed to load configuration from %s source: %+v", source.Name(), err)) + + continue + } + + err = source.Merge(ko, val) + if err != nil { + val.Push(fmt.Errorf("failed to merge configuration from %s source: %+v", source.Name(), err)) + + continue + } + } + + return nil +} diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go new file mode 100644 index 00000000..05cedfc7 --- /dev/null +++ b/internal/configuration/provider_test.go @@ -0,0 +1,287 @@ +package configuration + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/configuration/validator" + "github.com/authelia/authelia/internal/utils" +) + +func TestShouldErrorSecretNotExist(t *testing.T) { + testReset() + + dir, err := ioutil.TempDir("", "authelia-test-secret-not-exist") + assert.NoError(t, err) + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET_FILE", filepath.Join(dir, "jwt"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"DUO_API_SECRET_KEY_FILE", filepath.Join(dir, "duo"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET_FILE", filepath.Join(dir, "session"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", filepath.Join(dir, "authentication"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"NOTIFIER_SMTP_PASSWORD_FILE", filepath.Join(dir, "notifier"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_REDIS_PASSWORD_FILE", filepath.Join(dir, "redis"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE", filepath.Join(dir, "redis-sentinel"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD_FILE", filepath.Join(dir, "mysql"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_POSTGRES_PASSWORD_FILE", filepath.Join(dir, "postgres"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"TLS_KEY_FILE", filepath.Join(dir, "tls"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE", filepath.Join(dir, "oidc-key"))) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE", filepath.Join(dir, "oidc-hmac"))) + + val := schema.NewStructValidator() + _, _, err = Load(val, NewEnvironmentSource(DefaultEnvPrefix, DefaultEnvDelimiter), NewSecretsSource(DefaultEnvPrefix, DefaultEnvDelimiter)) + + assert.NoError(t, err) + assert.Len(t, val.Warnings(), 0) + + errs := val.Errors() + require.Len(t, errs, 12) + + sort.Sort(utils.ErrSliceSortAlphabetical(errs)) + + errFmt := utils.GetExpectedErrTxt("filenotfound") + + // ignore the errors before this as they are checked by the valdator. + assert.EqualError(t, errs[0], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "authentication"), "authentication_backend.ldap.password", fmt.Sprintf(errFmt, filepath.Join(dir, "authentication")))) + assert.EqualError(t, errs[1], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "duo"), "duo_api.secret_key", fmt.Sprintf(errFmt, filepath.Join(dir, "duo")))) + assert.EqualError(t, errs[2], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "jwt"), "jwt_secret", fmt.Sprintf(errFmt, filepath.Join(dir, "jwt")))) + assert.EqualError(t, errs[3], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "mysql"), "storage.mysql.password", fmt.Sprintf(errFmt, filepath.Join(dir, "mysql")))) + assert.EqualError(t, errs[4], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "notifier"), "notifier.smtp.password", fmt.Sprintf(errFmt, filepath.Join(dir, "notifier")))) + assert.EqualError(t, errs[5], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "oidc-hmac"), "identity_providers.oidc.hmac_secret", fmt.Sprintf(errFmt, filepath.Join(dir, "oidc-hmac")))) + assert.EqualError(t, errs[6], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "oidc-key"), "identity_providers.oidc.issuer_private_key", fmt.Sprintf(errFmt, filepath.Join(dir, "oidc-key")))) + assert.EqualError(t, errs[7], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "postgres"), "storage.postgres.password", fmt.Sprintf(errFmt, filepath.Join(dir, "postgres")))) + assert.EqualError(t, errs[8], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "redis"), "session.redis.password", fmt.Sprintf(errFmt, filepath.Join(dir, "redis")))) + assert.EqualError(t, errs[9], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "redis-sentinel"), "session.redis.high_availability.sentinel_password", fmt.Sprintf(errFmt, filepath.Join(dir, "redis-sentinel")))) + assert.EqualError(t, errs[10], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "session"), "session.secret", fmt.Sprintf(errFmt, filepath.Join(dir, "session")))) + assert.EqualError(t, errs[11], fmt.Sprintf(errFmtSecretIOIssue, filepath.Join(dir, "tls"), "tls_key", fmt.Sprintf(errFmt, filepath.Join(dir, "tls")))) +} + +func TestLoadShouldReturnErrWithoutValidator(t *testing.T) { + _, _, err := Load(nil, NewEnvironmentSource(DefaultEnvPrefix, DefaultEnvDelimiter)) + assert.EqualError(t, err, "no validator provided") +} + +func TestLoadShouldReturnErrWithoutSources(t *testing.T) { + _, _, err := Load(schema.NewStructValidator()) + assert.EqualError(t, err, "no sources provided") +} + +func TestShouldHaveNotifier(t *testing.T) { + testReset() + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")) + + val := schema.NewStructValidator() + _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + assert.Len(t, val.Errors(), 0) + assert.Len(t, val.Warnings(), 0) + assert.NotNil(t, config.Notifier) +} + +func TestShouldValidateConfigurationWithEnv(t *testing.T) { + testReset() + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")) + + val := schema.NewStructValidator() + _, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + assert.Len(t, val.Errors(), 0) + assert.Len(t, val.Warnings(), 0) +} + +func TestShouldNotIgnoreInvalidEnvs(t *testing.T) { + testReset() + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "an env session secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "an env storage mysql password")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL", "a bad env")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "an env jwt secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "an env authentication backend ldap password")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_URL", "an env authentication backend ldap password")) + + val := schema.NewStructValidator() + keys, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + + validator.ValidateKeys(keys, DefaultEnvPrefix, val) + + require.Len(t, val.Warnings(), 1) + assert.Len(t, val.Errors(), 0) + + assert.EqualError(t, val.Warnings()[0], fmt.Sprintf("configuration environment variable not expected: %sSTORAGE_MYSQL", DefaultEnvPrefix)) +} + +func TestShouldValidateAndRaiseErrorsOnNormalConfigurationAndSecret(t *testing.T) { + testReset() + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "an env session secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET_FILE", "./test_resources/example_secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "an env storage mysql password")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET_FILE", "./test_resources/example_secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "an env authentication backend ldap password")) + + val := schema.NewStructValidator() + _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + require.Len(t, val.Errors(), 1) + assert.Len(t, val.Warnings(), 0) + + assert.EqualError(t, val.Errors()[0], "secrets: error loading secret into key 'session.secret': it's already defined in other configuration sources") + + assert.Equal(t, "example_secret value", config.JWTSecret) + assert.Equal(t, "example_secret value", config.Session.Secret) + assert.Equal(t, "an env storage mysql password", config.Storage.MySQL.Password) + assert.Equal(t, "an env authentication backend ldap password", config.AuthenticationBackend.LDAP.Password) +} + +func TestShouldRaiseIOErrOnUnreadableFile(t *testing.T) { + if runtime.GOOS == constWindows { + t.Skip("skipping test due to being on windows") + } + + testReset() + + dir, err := ioutil.TempDir("", "authelia-conf") + assert.NoError(t, err) + + assert.NoError(t, os.WriteFile(filepath.Join(dir, "myconf.yml"), []byte("server:\n port: 9091\n"), 0000)) + + cfg := filepath.Join(dir, "myconf.yml") + + val := schema.NewStructValidator() + _, _, err = Load(val, NewYAMLFileSource(cfg)) + + assert.NoError(t, err) + require.Len(t, val.Errors(), 1) + assert.Len(t, val.Warnings(), 0) + assert.EqualError(t, val.Errors()[0], fmt.Sprintf("failed to load configuration from yaml file(%s) source: open %s: permission denied", cfg, cfg)) +} + +func TestShouldValidateConfigurationWithEnvSecrets(t *testing.T) { + testReset() + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET_FILE", "./test_resources/example_secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD_FILE", "./test_resources/example_secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET_FILE", "./test_resources/example_secret")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", "./test_resources/example_secret")) + + val := schema.NewStructValidator() + _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + assert.Len(t, val.Errors(), 0) + assert.Len(t, val.Warnings(), 0) + + assert.Equal(t, "example_secret value", config.JWTSecret) + assert.Equal(t, "example_secret value", config.Session.Secret) + assert.Equal(t, "example_secret value", config.AuthenticationBackend.LDAP.Password) + assert.Equal(t, "example_secret value", config.Storage.MySQL.Password) +} + +func TestShouldValidateAndRaiseErrorsOnBadConfiguration(t *testing.T) { + testReset() + + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "abc")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")) + + val := schema.NewStructValidator() + keys, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config_bad_keys.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + + validator.ValidateKeys(keys, DefaultEnvPrefix, val) + + require.Len(t, val.Errors(), 2) + assert.Len(t, val.Warnings(), 0) + + assert.EqualError(t, val.Errors()[0], "configuration key not expected: loggy_file") + assert.EqualError(t, val.Errors()[1], "invalid configuration key 'logs_level' was replaced by 'log.level'") +} + +func TestShouldNotReadConfigurationOnFSAccessDenied(t *testing.T) { + if runtime.GOOS == constWindows { + t.Skip("skipping test due to being on windows") + } + + testReset() + + dir, err := ioutil.TempDir("", "authelia-config") + assert.NoError(t, err) + + cfg := filepath.Join(dir, "config.yml") + assert.NoError(t, testCreateFile(filepath.Join(dir, "config.yml"), "port: 9091\n", 0000)) + + val := schema.NewStructValidator() + _, _, err = Load(val, NewYAMLFileSource(cfg)) + + assert.NoError(t, err) + require.Len(t, val.Errors(), 1) + + assert.EqualError(t, val.Errors()[0], fmt.Sprintf("failed to load configuration from yaml file(%s) source: open %s: permission denied", cfg, cfg)) +} + +func TestShouldNotLoadDirectoryConfiguration(t *testing.T) { + testReset() + + dir, err := ioutil.TempDir("", "authelia-config") + assert.NoError(t, err) + + val := schema.NewStructValidator() + _, _, err = Load(val, NewYAMLFileSource(dir)) + + assert.NoError(t, err) + require.Len(t, val.Errors(), 1) + assert.Len(t, val.Warnings(), 0) + + expectedErr := fmt.Sprintf(utils.GetExpectedErrTxt("yamlisdir"), dir) + assert.EqualError(t, val.Errors()[0], fmt.Sprintf("failed to load configuration from yaml file(%s) source: %s", dir, expectedErr)) +} + +func testReset() { + testUnsetEnvName("STORAGE_MYSQL") + testUnsetEnvName("JWT_SECRET") + testUnsetEnvName("DUO_API_SECRET_KEY") + testUnsetEnvName("SESSION_SECRET") + testUnsetEnvName("AUTHENTICATION_BACKEND_LDAP_PASSWORD") + testUnsetEnvName("AUTHENTICATION_BACKEND_LDAP_URL") + testUnsetEnvName("NOTIFIER_SMTP_PASSWORD") + testUnsetEnvName("SESSION_REDIS_PASSWORD") + testUnsetEnvName("SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD") + testUnsetEnvName("STORAGE_MYSQL_PASSWORD") + testUnsetEnvName("STORAGE_POSTGRES_PASSWORD") + testUnsetEnvName("TLS_KEY") + testUnsetEnvName("PORT") + testUnsetEnvName("IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY") + testUnsetEnvName("IDENTITY_PROVIDERS_OIDC_HMAC_SECRET") +} + +func testUnsetEnvName(name string) { + _ = os.Unsetenv(DefaultEnvPrefix + name) + _ = os.Unsetenv(DefaultEnvPrefix + name + constSecretSuffix) +} + +func testCreateFile(path, value string, perm os.FileMode) (err error) { + return os.WriteFile(path, []byte(value), perm) +} diff --git a/internal/configuration/reader.go b/internal/configuration/reader.go deleted file mode 100644 index 12a3ff1e..00000000 --- a/internal/configuration/reader.go +++ /dev/null @@ -1,99 +0,0 @@ -package configuration - -import ( - _ "embed" // Embed config.template.yml. - "errors" - "fmt" - "io/ioutil" - "os" - "strings" - - "github.com/spf13/viper" - "gopkg.in/yaml.v2" - - "github.com/authelia/authelia/internal/configuration/schema" - "github.com/authelia/authelia/internal/configuration/validator" - "github.com/authelia/authelia/internal/logging" -) - -// Read a YAML configuration and create a Configuration object out of it. -func Read(configPath string) (*schema.Configuration, []error) { - logger := logging.Logger() - - if configPath == "" { - return nil, []error{errors.New("No config file path provided")} - } - - _, err := os.Stat(configPath) - if err != nil { - errs := []error{ - fmt.Errorf("Unable to find config file: %v", configPath), - fmt.Errorf("Generating config file: %v", configPath), - } - - err = generateConfigFromTemplate(configPath) - if err != nil { - errs = append(errs, err) - } else { - errs = append(errs, fmt.Errorf("Generated configuration at: %v", configPath)) - } - - return nil, errs - } - - file, err := ioutil.ReadFile(configPath) - if err != nil { - return nil, []error{fmt.Errorf("Failed to %v", err)} - } - - var data interface{} - - err = yaml.Unmarshal(file, &data) - if err != nil { - return nil, []error{fmt.Errorf("Error malformed %v", err)} - } - - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - - // Dynamically load the secret env names from the SecretNames map. - for _, secretName := range validator.SecretNames { - _ = viper.BindEnv(validator.SecretNameToEnvName(secretName)) - } - - viper.SetConfigFile(configPath) - - _ = viper.ReadInConfig() - - var configuration schema.Configuration - - viper.Unmarshal(&configuration) //nolint:errcheck // TODO: Legacy code, consider refactoring time permitting. - - 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() - } - - if val.HasWarnings() { - for _, warn := range val.Warnings() { - logger.Warnf(warn.Error()) - } - } - - return &configuration, nil -} - -//go:embed config.template.yml -var cfg []byte - -func generateConfigFromTemplate(configPath string) error { - err := ioutil.WriteFile(configPath, cfg, 0600) - if err != nil { - return fmt.Errorf("Unable to generate %v: %v", configPath, err) - } - - return nil -} diff --git a/internal/configuration/reader_test.go b/internal/configuration/reader_test.go deleted file mode 100644 index 102f91d2..00000000 --- a/internal/configuration/reader_test.go +++ /dev/null @@ -1,282 +0,0 @@ -package configuration - -import ( - "io/ioutil" - "os" - "path" - "runtime" - "sort" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/authelia/authelia/internal/authentication" - "github.com/authelia/authelia/internal/utils" -) - -func createTestingTempFile(t *testing.T, dir, name, content string) { - err := ioutil.WriteFile(path.Join(dir, name), []byte(content), 0600) - require.NoError(t, err) -} - -func resetEnv() { - _ = os.Unsetenv("AUTHELIA_JWT_SECRET_FILE") - _ = os.Unsetenv("AUTHELIA_DUO_API_SECRET_KEY_FILE") - _ = os.Unsetenv("AUTHELIA_SESSION_SECRET_FILE") - _ = os.Unsetenv("AUTHELIA_SESSION_SECRET_FILE") - _ = os.Unsetenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE") - _ = os.Unsetenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE") - _ = os.Unsetenv("AUTHELIA_SESSION_REDIS_PASSWORD_FILE") - _ = os.Unsetenv("AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE") - _ = os.Unsetenv("AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE") - _ = os.Unsetenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE") -} - -func setupEnv(t *testing.T) string { - resetEnv() - - dirEnv := os.Getenv("AUTHELIA_TESTING_DIR") - if dirEnv != "" { - return dirEnv - } - - dir := "/tmp/authelia" + utils.RandomString(10, authentication.HashingPossibleSaltCharacters) + "/" - err := os.MkdirAll(dir, 0700) - require.NoError(t, err) - - createTestingTempFile(t, dir, "jwt", "secret_from_env") - createTestingTempFile(t, dir, "duo", "duo_secret_from_env") - createTestingTempFile(t, dir, "session", "session_secret_from_env") - createTestingTempFile(t, dir, "authentication", "ldap_secret_from_env") - createTestingTempFile(t, dir, "notifier", "smtp_secret_from_env") - createTestingTempFile(t, dir, "redis", "redis_secret_from_env") - createTestingTempFile(t, dir, "redis-sentinel", "redis-sentinel_secret_from_env") - createTestingTempFile(t, dir, "mysql", "mysql_secret_from_env") - createTestingTempFile(t, dir, "postgres", "postgres_secret_from_env") - - require.NoError(t, os.Setenv("AUTHELIA_TESTING_DIR", dir)) - - return dir -} - -func TestShouldErrorNoConfigPath(t *testing.T) { - _, errors := Read("") - - require.Len(t, errors, 1) - - require.EqualError(t, errors[0], "No config file path provided") -} - -func TestShouldErrorSecretNotExist(t *testing.T) { - dir := "/path/not/exist" - - require.NoError(t, os.Setenv("AUTHELIA_JWT_SECRET_FILE", dir+"jwt")) - require.NoError(t, os.Setenv("AUTHELIA_DUO_API_SECRET_KEY_FILE", dir+"duo")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_SECRET_FILE", dir+"session")) - require.NoError(t, os.Setenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", dir+"authentication")) - require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE", dir+"notifier")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD_FILE", dir+"redis")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE", dir+"redis-sentinel")) - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE", dir+"mysql")) - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE", dir+"postgres")) - - _, errors := Read("./test_resources/config.yml") - - require.Len(t, errors, 12) - - if runtime.GOOS == windows { - assert.EqualError(t, errors[0], "error loading secret file (jwt_secret): open /path/not/existjwt: The system cannot find the path specified.") - assert.EqualError(t, errors[1], "error loading secret file (session.secret): open /path/not/existsession: The system cannot find the path specified.") - assert.EqualError(t, errors[2], "error loading secret file (duo_api.secret_key): open /path/not/existduo: The system cannot find the path specified.") - assert.EqualError(t, errors[3], "error loading secret file (session.redis.password): open /path/not/existredis: The system cannot find the path specified.") - assert.EqualError(t, errors[4], "error loading secret file (session.redis.high_availability.sentinel_password): open /path/not/existredis-sentinel: The system cannot find the path specified.") - assert.EqualError(t, errors[5], "error loading secret file (authentication_backend.ldap.password): open /path/not/existauthentication: The system cannot find the path specified.") - assert.EqualError(t, errors[6], "error loading secret file (notifier.smtp.password): open /path/not/existnotifier: The system cannot find the path specified.") - assert.EqualError(t, errors[7], "error loading secret file (storage.mysql.password): open /path/not/existmysql: The system cannot find the path specified.") - } else { - assert.EqualError(t, errors[0], "error loading secret file (jwt_secret): open /path/not/existjwt: no such file or directory") - assert.EqualError(t, errors[1], "error loading secret file (session.secret): open /path/not/existsession: no such file or directory") - assert.EqualError(t, errors[2], "error loading secret file (duo_api.secret_key): open /path/not/existduo: no such file or directory") - assert.EqualError(t, errors[3], "error loading secret file (session.redis.password): open /path/not/existredis: no such file or directory") - assert.EqualError(t, errors[4], "error loading secret file (session.redis.high_availability.sentinel_password): open /path/not/existredis-sentinel: no such file or directory") - assert.EqualError(t, errors[5], "error loading secret file (authentication_backend.ldap.password): open /path/not/existauthentication: no such file or directory") - assert.EqualError(t, errors[6], "error loading secret file (notifier.smtp.password): open /path/not/existnotifier: no such file or directory") - assert.EqualError(t, errors[7], "error loading secret file (storage.mysql.password): open /path/not/existmysql: no such file or directory") - } - - assert.EqualError(t, errors[8], "Provide a JWT secret using \"jwt_secret\" key") - assert.EqualError(t, errors[9], "Please provide a password to connect to the LDAP server") - assert.EqualError(t, errors[10], "The session secret must be set when using the redis sentinel session provider") - assert.EqualError(t, errors[11], "the SQL username and password must be provided") -} - -func TestShouldErrorPermissionsOnLocalFS(t *testing.T) { - if runtime.GOOS == windows { - t.Skip("skipping test due to being on windows") - } - - resetEnv() - - _ = os.Mkdir("/tmp/noperms/", 0000) - _, errors := Read("/tmp/noperms/configuration.yml") - - require.Len(t, errors, 3) - - require.EqualError(t, errors[0], "Unable to find config file: /tmp/noperms/configuration.yml") - require.EqualError(t, errors[1], "Generating config file: /tmp/noperms/configuration.yml") - require.EqualError(t, errors[2], "Unable to generate /tmp/noperms/configuration.yml: open /tmp/noperms/configuration.yml: permission denied") -} - -func TestShouldErrorAndGenerateConfigFile(t *testing.T) { - _, errors := Read("./nonexistent.yml") - _ = os.Remove("./nonexistent.yml") - - require.Len(t, errors, 3) - - require.EqualError(t, errors[0], "Unable to find config file: ./nonexistent.yml") - require.EqualError(t, errors[1], "Generating config file: ./nonexistent.yml") - require.EqualError(t, errors[2], "Generated configuration at: ./nonexistent.yml") -} - -func TestShouldErrorPermissionsConfigFile(t *testing.T) { - resetEnv() - - _ = ioutil.WriteFile("/tmp/authelia/permissions.yml", []byte{}, 0000) // nolint:gosec - _, errors := Read("/tmp/authelia/permissions.yml") - - if runtime.GOOS == windows { - require.Len(t, errors, 5) - assert.EqualError(t, errors[0], "Provide a JWT secret using \"jwt_secret\" key") - assert.EqualError(t, errors[1], "Please provide `ldap` or `file` object in `authentication_backend`") - assert.EqualError(t, errors[2], "Set domain of the session object") - assert.EqualError(t, errors[3], "A storage configuration must be provided. It could be 'local', 'mysql' or 'postgres'") - assert.EqualError(t, errors[4], "A notifier configuration must be provided") - } else { - require.Len(t, errors, 1) - - assert.EqualError(t, errors[0], "Failed to open /tmp/authelia/permissions.yml: permission denied") - } -} - -func TestShouldErrorParseBadConfigFile(t *testing.T) { - _, errors := Read("./test_resources/config_bad_quoting.yml") - - require.Len(t, errors, 1) - - require.EqualError(t, errors[0], "Error malformed yaml: line 27: did not find expected alphabetic or numeric character") -} - -func TestShouldParseConfigFile(t *testing.T) { - dir := setupEnv(t) - - require.NoError(t, os.Setenv("AUTHELIA_JWT_SECRET_FILE", dir+"jwt")) - require.NoError(t, os.Setenv("AUTHELIA_DUO_API_SECRET_KEY_FILE", dir+"duo")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_SECRET_FILE", dir+"session")) - require.NoError(t, os.Setenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", dir+"authentication")) - require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE", dir+"notifier")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD_FILE", dir+"redis")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE", dir+"redis-sentinel")) - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE", dir+"mysql")) - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE", dir+"postgres")) - - config, errors := Read("./test_resources/config.yml") - - require.Len(t, errors, 0) - - assert.Equal(t, 9091, config.Server.Port) - assert.Equal(t, "debug", config.Logging.Level) - assert.Equal(t, "https://home.example.com:8080/", config.DefaultRedirectionURL) - assert.Equal(t, "authelia.com", config.TOTP.Issuer) - assert.Equal(t, "secret_from_env", config.JWTSecret) - - assert.Equal(t, "api-123456789.example.com", config.DuoAPI.Hostname) - assert.Equal(t, "ABCDEF", config.DuoAPI.IntegrationKey) - assert.Equal(t, "duo_secret_from_env", config.DuoAPI.SecretKey) - - assert.Equal(t, "session_secret_from_env", config.Session.Secret) - assert.Equal(t, "ldap_secret_from_env", config.AuthenticationBackend.LDAP.Password) - assert.Equal(t, "smtp_secret_from_env", config.Notifier.SMTP.Password) - assert.Equal(t, "redis_secret_from_env", config.Session.Redis.Password) - assert.Equal(t, "redis-sentinel_secret_from_env", config.Session.Redis.HighAvailability.SentinelPassword) - assert.Equal(t, "mysql_secret_from_env", config.Storage.MySQL.Password) - - assert.Equal(t, "deny", config.AccessControl.DefaultPolicy) - assert.Len(t, config.AccessControl.Rules, 12) - - require.NotNil(t, config.Session) - require.NotNil(t, config.Session.Redis) - require.NotNil(t, config.Session.Redis.HighAvailability) -} - -func TestShouldParseAltConfigFile(t *testing.T) { - dir := setupEnv(t) - - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE", dir+"postgres")) - require.NoError(t, os.Setenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", dir+"authentication")) - require.NoError(t, os.Setenv("AUTHELIA_JWT_SECRET_FILE", dir+"jwt")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_SECRET_FILE", dir+"session")) - - config, errors := Read("./test_resources/config_alt.yml") - require.Len(t, errors, 0) - - assert.Equal(t, 9091, config.Server.Port) - assert.Equal(t, "debug", config.Logging.Level) - assert.Equal(t, "https://home.example.com:8080/", config.DefaultRedirectionURL) - assert.Equal(t, "authelia.com", config.TOTP.Issuer) - assert.Equal(t, "secret_from_env", config.JWTSecret) - - assert.Equal(t, "api-123456789.example.com", config.DuoAPI.Hostname) - assert.Equal(t, "ABCDEF", config.DuoAPI.IntegrationKey) - assert.Equal(t, "postgres_secret_from_env", config.Storage.PostgreSQL.Password) - - assert.Equal(t, "deny", config.AccessControl.DefaultPolicy) - assert.Len(t, config.AccessControl.Rules, 12) -} - -func TestShouldNotParseConfigFileWithOldOrUnexpectedKeys(t *testing.T) { - dir := setupEnv(t) - - require.NoError(t, os.Setenv("AUTHELIA_JWT_SECRET_FILE", dir+"jwt")) - require.NoError(t, os.Setenv("AUTHELIA_DUO_API_SECRET_KEY_FILE", dir+"duo")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_SECRET_FILE", dir+"session")) - require.NoError(t, os.Setenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", dir+"authentication")) - require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE", dir+"notifier")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD_FILE", dir+"redis")) - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE", dir+"mysql")) - - _, 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], "invalid configuration key 'logs_level' was replaced by 'log.level'") -} - -func TestShouldValidateConfigurationTemplate(t *testing.T) { - resetEnv() - - _, errors := Read("../../config.template.yml") - assert.Len(t, errors, 0) -} - -func TestShouldOnlyAllowEnvOrConfig(t *testing.T) { - dir := setupEnv(t) - - resetEnv() - require.NoError(t, os.Setenv("AUTHELIA_JWT_SECRET_FILE", dir+"jwt")) - require.NoError(t, os.Setenv("AUTHELIA_DUO_API_SECRET_KEY_FILE", dir+"duo")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_SECRET_FILE", dir+"session")) - require.NoError(t, os.Setenv("AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", dir+"authentication")) - require.NoError(t, os.Setenv("AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE", dir+"notifier")) - require.NoError(t, os.Setenv("AUTHELIA_SESSION_REDIS_PASSWORD_FILE", dir+"redis")) - require.NoError(t, os.Setenv("AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE", dir+"mysql")) - - _, errors := Read("./test_resources/config_with_secret.yml") - - require.Len(t, errors, 1) - require.EqualError(t, errors[0], "error loading secret (jwt_secret): it's already defined in the config file") -} diff --git a/internal/configuration/schema/access_control.go b/internal/configuration/schema/access_control.go index e2d5bbc7..b251e85e 100644 --- a/internal/configuration/schema/access_control.go +++ b/internal/configuration/schema/access_control.go @@ -2,25 +2,25 @@ package schema // AccessControlConfiguration represents the configuration related to ACLs. type AccessControlConfiguration struct { - DefaultPolicy string `mapstructure:"default_policy"` - Networks []ACLNetwork `mapstructure:"networks"` - Rules []ACLRule `mapstructure:"rules"` + DefaultPolicy string `koanf:"default_policy"` + Networks []ACLNetwork `koanf:"networks"` + Rules []ACLRule `koanf:"rules"` } // ACLNetwork represents one ACL network group entry; "weak" coerces a single value into slice. type ACLNetwork struct { - Name string `mapstructure:"name"` - Networks []string `mapstructure:"networks"` + Name string `koanf:"name"` + Networks []string `koanf:"networks"` } // ACLRule represents one ACL rule entry; "weak" coerces a single value into slice. type ACLRule struct { - Domains []string `mapstructure:"domain,weak"` - Policy string `mapstructure:"policy"` - Subjects [][]string `mapstructure:"subject,weak"` - Networks []string `mapstructure:"networks"` - Resources []string `mapstructure:"resources"` - Methods []string `mapstructure:"methods"` + Domains []string `koanf:"domain"` + Policy string `koanf:"policy"` + Subjects [][]string `koanf:"subject"` + Networks []string `koanf:"networks"` + Resources []string `koanf:"resources"` + Methods []string `koanf:"methods"` } // DefaultACLNetwork represents the default configuration related to access control network group configuration. diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index 85028eaf..c5d72a1b 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -2,45 +2,45 @@ package schema // LDAPAuthenticationBackendConfiguration represents the configuration related to LDAP server. type LDAPAuthenticationBackendConfiguration struct { - Implementation string `mapstructure:"implementation"` - URL string `mapstructure:"url"` - BaseDN string `mapstructure:"base_dn"` - AdditionalUsersDN string `mapstructure:"additional_users_dn"` - UsersFilter string `mapstructure:"users_filter"` - AdditionalGroupsDN string `mapstructure:"additional_groups_dn"` - GroupsFilter string `mapstructure:"groups_filter"` - GroupNameAttribute string `mapstructure:"group_name_attribute"` - UsernameAttribute string `mapstructure:"username_attribute"` - MailAttribute string `mapstructure:"mail_attribute"` - DisplayNameAttribute string `mapstructure:"display_name_attribute"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - StartTLS bool `mapstructure:"start_tls"` - TLS *TLSConfig `mapstructure:"tls"` + Implementation string `koanf:"implementation"` + URL string `koanf:"url"` + BaseDN string `koanf:"base_dn"` + AdditionalUsersDN string `koanf:"additional_users_dn"` + UsersFilter string `koanf:"users_filter"` + AdditionalGroupsDN string `koanf:"additional_groups_dn"` + GroupsFilter string `koanf:"groups_filter"` + GroupNameAttribute string `koanf:"group_name_attribute"` + UsernameAttribute string `koanf:"username_attribute"` + MailAttribute string `koanf:"mail_attribute"` + DisplayNameAttribute string `koanf:"display_name_attribute"` + User string `koanf:"user"` + Password string `koanf:"password"` + StartTLS bool `koanf:"start_tls"` + TLS *TLSConfig `koanf:"tls"` } // FileAuthenticationBackendConfiguration represents the configuration related to file-based backend. type FileAuthenticationBackendConfiguration struct { - Path string `mapstructure:"path"` - Password *PasswordConfiguration `mapstructure:"password"` + Path string `koanf:"path"` + Password *PasswordConfiguration `koanf:"password"` } // PasswordConfiguration represents the configuration related to password hashing. type PasswordConfiguration struct { - Iterations int `mapstructure:"iterations"` - KeyLength int `mapstructure:"key_length"` - SaltLength int `mapstructure:"salt_length"` + Iterations int `koanf:"iterations"` + KeyLength int `koanf:"key_length"` + SaltLength int `koanf:"salt_length"` Algorithm string `mapstrucutre:"algorithm"` - Memory int `mapstructure:"memory"` - Parallelism int `mapstructure:"parallelism"` + Memory int `koanf:"memory"` + Parallelism int `koanf:"parallelism"` } // AuthenticationBackendConfiguration represents the configuration related to the authentication backend. type AuthenticationBackendConfiguration struct { - DisableResetPassword bool `mapstructure:"disable_reset_password"` - RefreshInterval string `mapstructure:"refresh_interval"` - LDAP *LDAPAuthenticationBackendConfiguration `mapstructure:"ldap"` - File *FileAuthenticationBackendConfiguration `mapstructure:"file"` + DisableResetPassword bool `koanf:"disable_reset_password"` + RefreshInterval string `koanf:"refresh_interval"` + LDAP *LDAPAuthenticationBackendConfiguration `koanf:"ldap"` + File *FileAuthenticationBackendConfiguration `koanf:"file"` } // DefaultPasswordConfiguration represents the default configuration related to Argon2id hashing. diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go index 1ef567bc..47339ce7 100644 --- a/internal/configuration/schema/configuration.go +++ b/internal/configuration/schema/configuration.go @@ -2,10 +2,10 @@ package schema // Configuration object extracted from YAML configuration file. type Configuration struct { - Theme string `mapstructure:"theme"` - CertificatesDirectory string `mapstructure:"certificates_directory"` - JWTSecret string `mapstructure:"jwt_secret"` - DefaultRedirectionURL string `mapstructure:"default_redirection_url"` + Theme string `koanf:"theme"` + CertificatesDirectory string `koanf:"certificates_directory"` + JWTSecret string `koanf:"jwt_secret"` + DefaultRedirectionURL string `koanf:"default_redirection_url"` Host string `koanf:"host"` // Deprecated: To be Removed. TODO: Remove in 4.33.0. Port int `koanf:"port"` // Deprecated: To be Removed. TODO: Remove in 4.33.0. @@ -15,15 +15,15 @@ type Configuration struct { LogFormat string `koanf:"log_format"` // Deprecated: To be Removed. TODO: Remove in 4.33.0. LogFilePath string `koanf:"log_file_path"` // Deprecated: To be Removed. TODO: Remove in 4.33.0. - Logging LogConfiguration `mapstructure:"log"` - IdentityProviders IdentityProvidersConfiguration `mapstructure:"identity_providers"` - AuthenticationBackend AuthenticationBackendConfiguration `mapstructure:"authentication_backend"` - Session SessionConfiguration `mapstructure:"session"` - TOTP *TOTPConfiguration `mapstructure:"totp"` - DuoAPI *DuoAPIConfiguration `mapstructure:"duo_api"` - AccessControl AccessControlConfiguration `mapstructure:"access_control"` - Regulation *RegulationConfiguration `mapstructure:"regulation"` - Storage StorageConfiguration `mapstructure:"storage"` - Notifier *NotifierConfiguration `mapstructure:"notifier"` - Server ServerConfiguration `mapstructure:"server"` + Log LogConfiguration `koanf:"log"` + IdentityProviders IdentityProvidersConfiguration `koanf:"identity_providers"` + AuthenticationBackend AuthenticationBackendConfiguration `koanf:"authentication_backend"` + Session SessionConfiguration `koanf:"session"` + TOTP *TOTPConfiguration `koanf:"totp"` + DuoAPI *DuoAPIConfiguration `koanf:"duo_api"` + AccessControl AccessControlConfiguration `koanf:"access_control"` + Regulation *RegulationConfiguration `koanf:"regulation"` + Storage StorageConfiguration `koanf:"storage"` + Notifier *NotifierConfiguration `koanf:"notifier"` + Server ServerConfiguration `koanf:"server"` } diff --git a/internal/configuration/schema/duo.go b/internal/configuration/schema/duo.go index a4bfab22..520c724e 100644 --- a/internal/configuration/schema/duo.go +++ b/internal/configuration/schema/duo.go @@ -2,7 +2,7 @@ package schema // DuoAPIConfiguration represents the configuration related to Duo API. type DuoAPIConfiguration struct { - Hostname string `mapstructure:"hostname"` - IntegrationKey string `mapstructure:"integration_key"` - SecretKey string `mapstructure:"secret_key"` + Hostname string `koanf:"hostname"` + IntegrationKey string `koanf:"integration_key"` + SecretKey string `koanf:"secret_key"` } diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index 90a1de66..d22734bc 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -4,42 +4,43 @@ import "time" // IdentityProvidersConfiguration represents the IdentityProviders 2.0 configuration for Authelia. type IdentityProvidersConfiguration struct { - OIDC *OpenIDConnectConfiguration `mapstructure:"oidc"` + OIDC *OpenIDConnectConfiguration `koanf:"oidc"` } // OpenIDConnectConfiguration configuration for OpenID Connect. type OpenIDConnectConfiguration struct { // This secret must be 32 bytes long - HMACSecret string `mapstructure:"hmac_secret"` - IssuerPrivateKey string `mapstructure:"issuer_private_key"` + HMACSecret string `koanf:"hmac_secret"` + IssuerPrivateKey string `koanf:"issuer_private_key"` - AccessTokenLifespan time.Duration `mapstructure:"access_token_lifespan"` - AuthorizeCodeLifespan time.Duration `mapstructure:"authorize_code_lifespan"` - IDTokenLifespan time.Duration `mapstructure:"id_token_lifespan"` - RefreshTokenLifespan time.Duration `mapstructure:"refresh_token_lifespan"` - EnableClientDebugMessages bool `mapstructure:"enable_client_debug_messages"` - MinimumParameterEntropy int `mapstructure:"minimum_parameter_entropy"` + AccessTokenLifespan time.Duration `koanf:"access_token_lifespan"` + AuthorizeCodeLifespan time.Duration `koanf:"authorize_code_lifespan"` + IDTokenLifespan time.Duration `koanf:"id_token_lifespan"` + RefreshTokenLifespan time.Duration `koanf:"refresh_token_lifespan"` - Clients []OpenIDConnectClientConfiguration `mapstructure:"clients"` + EnableClientDebugMessages bool `koanf:"enable_client_debug_messages"` + MinimumParameterEntropy int `koanf:"minimum_parameter_entropy"` + + Clients []OpenIDConnectClientConfiguration `koanf:"clients"` } // OpenIDConnectClientConfiguration configuration for an OpenID Connect client. type OpenIDConnectClientConfiguration struct { - ID string `mapstructure:"id"` - Description string `mapstructure:"description"` - Secret string `mapstructure:"secret"` - Public bool `mapstructure:"public"` + ID string `koanf:"id"` + Description string `koanf:"description"` + Secret string `koanf:"secret"` + Public bool `koanf:"public"` - Policy string `mapstructure:"authorization_policy"` + Policy string `koanf:"authorization_policy"` - Audience []string `mapstructure:"audience"` - Scopes []string `mapstructure:"scopes"` - RedirectURIs []string `mapstructure:"redirect_uris"` - GrantTypes []string `mapstructure:"grant_types"` - ResponseTypes []string `mapstructure:"response_types"` - ResponseModes []string `mapstructure:"response_modes"` + Audience []string `koanf:"audience"` + Scopes []string `koanf:"scopes"` + RedirectURIs []string `koanf:"redirect_uris"` + GrantTypes []string `koanf:"grant_types"` + ResponseTypes []string `koanf:"response_types"` + ResponseModes []string `koanf:"response_modes"` - UserinfoSigningAlgorithm string `mapstructure:"userinfo_signing_algorithm"` + UserinfoSigningAlgorithm string `koanf:"userinfo_signing_algorithm"` } // DefaultOpenIDConnectConfiguration contains defaults for OIDC. diff --git a/internal/configuration/schema/log.go b/internal/configuration/schema/log.go index 81e38c2a..0678d860 100644 --- a/internal/configuration/schema/log.go +++ b/internal/configuration/schema/log.go @@ -2,10 +2,10 @@ package schema // LogConfiguration represents the logging configuration. type LogConfiguration struct { - Level string `mapstructure:"level"` - Format string `mapstructure:"format"` - FilePath string `mapstructure:"file_path"` - KeepStdout bool `mapstructure:"keep_stdout"` + Level string `koanf:"level"` + Format string `koanf:"format"` + FilePath string `koanf:"file_path"` + KeepStdout bool `koanf:"keep_stdout"` } // DefaultLoggingConfiguration is the default logging configuration. diff --git a/internal/configuration/schema/notifier.go b/internal/configuration/schema/notifier.go index bb6b878b..9e582c27 100644 --- a/internal/configuration/schema/notifier.go +++ b/internal/configuration/schema/notifier.go @@ -2,29 +2,29 @@ package schema // FileSystemNotifierConfiguration represents the configuration of the notifier writing emails in a file. type FileSystemNotifierConfiguration struct { - Filename string `mapstructure:"filename"` + Filename string `koanf:"filename"` } // SMTPNotifierConfiguration represents the configuration of the SMTP server to send emails with. type SMTPNotifierConfiguration struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - Identifier string `mapstructure:"identifier"` - Sender string `mapstructure:"sender"` - Subject string `mapstructure:"subject"` - StartupCheckAddress string `mapstructure:"startup_check_address"` - DisableRequireTLS bool `mapstructure:"disable_require_tls"` - DisableHTMLEmails bool `mapstructure:"disable_html_emails"` - TLS *TLSConfig `mapstructure:"tls"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Username string `koanf:"username"` + Password string `koanf:"password"` + Identifier string `koanf:"identifier"` + Sender string `koanf:"sender"` + Subject string `koanf:"subject"` + StartupCheckAddress string `koanf:"startup_check_address"` + DisableRequireTLS bool `koanf:"disable_require_tls"` + DisableHTMLEmails bool `koanf:"disable_html_emails"` + TLS *TLSConfig `koanf:"tls"` } // NotifierConfiguration represents the configuration of the notifier to use when sending notifications to users. type NotifierConfiguration struct { - DisableStartupCheck bool `mapstructure:"disable_startup_check"` - FileSystem *FileSystemNotifierConfiguration `mapstructure:"filesystem"` - SMTP *SMTPNotifierConfiguration `mapstructure:"smtp"` + DisableStartupCheck bool `koanf:"disable_startup_check"` + FileSystem *FileSystemNotifierConfiguration `koanf:"filesystem"` + SMTP *SMTPNotifierConfiguration `koanf:"smtp"` } // DefaultSMTPNotifierConfiguration represents default configuration parameters for the SMTP notifier. diff --git a/internal/configuration/schema/regulation.go b/internal/configuration/schema/regulation.go index e4b3b466..61002736 100644 --- a/internal/configuration/schema/regulation.go +++ b/internal/configuration/schema/regulation.go @@ -2,9 +2,9 @@ package schema // RegulationConfiguration represents the configuration related to regulation. type RegulationConfiguration struct { - MaxRetries int `mapstructure:"max_retries"` - FindTime string `mapstructure:"find_time"` - BanTime string `mapstructure:"ban_time"` + MaxRetries int `koanf:"max_retries"` + FindTime string `koanf:"find_time,weak"` + BanTime string `koanf:"ban_time,weak"` } // DefaultRegulationConfiguration represents default configuration parameters for the regulator. diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go index 541df99e..07169ad8 100644 --- a/internal/configuration/schema/server.go +++ b/internal/configuration/schema/server.go @@ -2,21 +2,21 @@ package schema // ServerConfiguration represents the configuration of the http server. type ServerConfiguration struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Path string `mapstructure:"path"` - ReadBufferSize int `mapstructure:"read_buffer_size"` - WriteBufferSize int `mapstructure:"write_buffer_size"` - EnablePprof bool `mapstructure:"enable_endpoint_pprof"` - EnableExpvars bool `mapstructure:"enable_endpoint_expvars"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Path string `koanf:"path"` + ReadBufferSize int `koanf:"read_buffer_size"` + WriteBufferSize int `koanf:"write_buffer_size"` + EnablePprof bool `koanf:"enable_endpoint_pprof"` + EnableExpvars bool `koanf:"enable_endpoint_expvars"` - TLS ServerTLSConfiguration `mapstructure:"tls"` + TLS ServerTLSConfiguration `koanf:"tls"` } // ServerTLSConfiguration represents the configuration of the http servers TLS options. type ServerTLSConfiguration struct { - Certificate string `mapstructure:"certificate"` - Key string `mapstructure:"key"` + Certificate string `koanf:"certificate"` + Key string `koanf:"key"` } // DefaultServerConfiguration represents the default values of the ServerConfiguration. diff --git a/internal/configuration/schema/session.go b/internal/configuration/schema/session.go index 3a29aba3..ca9aac6c 100644 --- a/internal/configuration/schema/session.go +++ b/internal/configuration/schema/session.go @@ -2,42 +2,42 @@ package schema // RedisNode Represents a Node. type RedisNode struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` + Host string `koanf:"host"` + Port int `koanf:"port"` } // RedisHighAvailabilityConfiguration holds configuration variables for Redis Cluster/Sentinel. type RedisHighAvailabilityConfiguration struct { - SentinelName string `mapstructure:"sentinel_name"` - SentinelPassword string `mapstructure:"sentinel_password"` - Nodes []RedisNode `mapstructure:"nodes"` - RouteByLatency bool `mapstructure:"route_by_latency"` - RouteRandomly bool `mapstructure:"route_randomly"` + SentinelName string `koanf:"sentinel_name"` + SentinelPassword string `koanf:"sentinel_password"` + Nodes []RedisNode `koanf:"nodes"` + RouteByLatency bool `koanf:"route_by_latency"` + RouteRandomly bool `koanf:"route_randomly"` } // RedisSessionConfiguration represents the configuration related to redis session store. type RedisSessionConfiguration struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - DatabaseIndex int `mapstructure:"database_index"` - MaximumActiveConnections int `mapstructure:"maximum_active_connections"` - MinimumIdleConnections int `mapstructure:"minimum_idle_connections"` - TLS *TLSConfig `mapstructure:"tls"` - HighAvailability *RedisHighAvailabilityConfiguration `mapstructure:"high_availability"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Username string `koanf:"username"` + Password string `koanf:"password"` + DatabaseIndex int `koanf:"database_index"` + MaximumActiveConnections int `koanf:"maximum_active_connections"` + MinimumIdleConnections int `koanf:"minimum_idle_connections"` + TLS *TLSConfig `koanf:"tls"` + HighAvailability *RedisHighAvailabilityConfiguration `koanf:"high_availability"` } // SessionConfiguration represents the configuration related to user sessions. type SessionConfiguration struct { - Name string `mapstructure:"name"` - Domain string `mapstructure:"domain"` - SameSite string `mapstructure:"same_site"` - Secret string `mapstructure:"secret"` - Expiration string `mapstructure:"expiration"` - Inactivity string `mapstructure:"inactivity"` - RememberMeDuration string `mapstructure:"remember_me_duration"` - Redis *RedisSessionConfiguration `mapstructure:"redis"` + Name string `koanf:"name"` + Domain string `koanf:"domain"` + SameSite string `koanf:"same_site"` + Secret string `koanf:"secret"` + Expiration string `koanf:"expiration"` + Inactivity string `koanf:"inactivity"` + RememberMeDuration string `koanf:"remember_me_duration"` + Redis *RedisSessionConfiguration `koanf:"redis"` } // DefaultSessionConfiguration is the default session configuration. diff --git a/internal/configuration/schema/shared.go b/internal/configuration/schema/shared.go index 46a48c9e..46cf5784 100644 --- a/internal/configuration/schema/shared.go +++ b/internal/configuration/schema/shared.go @@ -2,7 +2,7 @@ package schema // TLSConfig is a representation of the TLS configuration. type TLSConfig struct { - MinimumVersion string `mapstructure:"minimum_version"` - SkipVerify bool `mapstructure:"skip_verify"` - ServerName string `mapstructure:"server_name"` + MinimumVersion string `koanf:"minimum_version"` + SkipVerify bool `koanf:"skip_verify"` + ServerName string `koanf:"server_name"` } diff --git a/internal/configuration/schema/storage.go b/internal/configuration/schema/storage.go index 28196986..168b61bc 100644 --- a/internal/configuration/schema/storage.go +++ b/internal/configuration/schema/storage.go @@ -2,32 +2,32 @@ package schema // LocalStorageConfiguration represents the configuration when using local storage. type LocalStorageConfiguration struct { - Path string `mapstructure:"path"` + Path string `koanf:"path"` } // SQLStorageConfiguration represents the configuration of the SQL database. type SQLStorageConfiguration struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Database string `mapstructure:"database"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Database string `koanf:"database"` + Username string `koanf:"username"` + Password string `koanf:"password"` } // MySQLStorageConfiguration represents the configuration of a MySQL database. type MySQLStorageConfiguration struct { - SQLStorageConfiguration `mapstructure:",squash"` + SQLStorageConfiguration `koanf:",squash"` } // PostgreSQLStorageConfiguration represents the configuration of a Postgres database. type PostgreSQLStorageConfiguration struct { - SQLStorageConfiguration `mapstructure:",squash"` - SSLMode string `mapstructure:"sslmode"` + SQLStorageConfiguration `koanf:",squash"` + SSLMode string `koanf:"sslmode"` } // StorageConfiguration represents the configuration of the storage backend. type StorageConfiguration struct { - Local *LocalStorageConfiguration `mapstructure:"local"` - MySQL *MySQLStorageConfiguration `mapstructure:"mysql"` - PostgreSQL *PostgreSQLStorageConfiguration `mapstructure:"postgres"` + Local *LocalStorageConfiguration `koanf:"local"` + MySQL *MySQLStorageConfiguration `koanf:"mysql"` + PostgreSQL *PostgreSQLStorageConfiguration `koanf:"postgres"` } diff --git a/internal/configuration/schema/totp.go b/internal/configuration/schema/totp.go index 843d03c0..061f4736 100644 --- a/internal/configuration/schema/totp.go +++ b/internal/configuration/schema/totp.go @@ -2,9 +2,9 @@ package schema // TOTPConfiguration represents the configuration related to TOTP options. type TOTPConfiguration struct { - Issuer string `mapstructure:"issuer"` - Period int `mapstructure:"period"` - Skew *int `mapstructure:"skew"` + Issuer string `koanf:"issuer"` + Period int `koanf:"period"` + Skew *int `koanf:"skew"` } var defaultOtpSkew = 1 diff --git a/internal/configuration/sources.go b/internal/configuration/sources.go new file mode 100644 index 00000000..90510ee2 --- /dev/null +++ b/internal/configuration/sources.go @@ -0,0 +1,126 @@ +package configuration + +import ( + "errors" + "fmt" + + "github.com/knadh/koanf" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/configuration/validator" +) + +// NewYAMLFileSource returns a Source configured to load from a specified YAML path. If there is an issue accessing this +// path it also returns an error. +func NewYAMLFileSource(path string) (source *YAMLFileSource) { + return &YAMLFileSource{ + koanf: koanf.New(constDelimiter), + path: path, + } +} + +// NewYAMLFileSources returns a slice of Source configured to load from specified YAML files. +func NewYAMLFileSources(paths []string) (sources []*YAMLFileSource) { + for _, path := range paths { + source := NewYAMLFileSource(path) + + sources = append(sources, source) + } + + return sources +} + +// Name of the Source. +func (s YAMLFileSource) Name() (name string) { + return fmt.Sprintf("yaml file(%s)", s.path) +} + +// Merge the YAMLFileSource koanf.Koanf into the provided one. +func (s *YAMLFileSource) Merge(ko *koanf.Koanf, _ *schema.StructValidator) (err error) { + return ko.Merge(s.koanf) +} + +// Load the Source into the YAMLFileSource koanf.Koanf. +func (s *YAMLFileSource) Load(_ *schema.StructValidator) (err error) { + if s.path == "" { + return errors.New("invalid yaml path source configuration") + } + + return s.koanf.Load(file.Provider(s.path), yaml.Parser()) +} + +// NewEnvironmentSource returns a Source configured to load from environment variables. +func NewEnvironmentSource(prefix, delimiter string) (source *EnvironmentSource) { + return &EnvironmentSource{ + koanf: koanf.New(constDelimiter), + prefix: prefix, + delimiter: delimiter, + } +} + +// Name of the Source. +func (s EnvironmentSource) Name() (name string) { + return "environment" +} + +// Merge the EnvironmentSource koanf.Koanf into the provided one. +func (s *EnvironmentSource) Merge(ko *koanf.Koanf, _ *schema.StructValidator) (err error) { + return ko.Merge(s.koanf) +} + +// Load the Source into the EnvironmentSource koanf.Koanf. +func (s *EnvironmentSource) Load(_ *schema.StructValidator) (err error) { + keyMap, ignoredKeys := getEnvConfigMap(validator.ValidKeys, s.prefix, s.delimiter) + + return s.koanf.Load(env.ProviderWithValue(s.prefix, constDelimiter, koanfEnvironmentCallback(keyMap, ignoredKeys, s.prefix, s.delimiter)), nil) +} + +// NewSecretsSource returns a Source configured to load from secrets. +func NewSecretsSource(prefix, delimiter string) (source *SecretsSource) { + return &SecretsSource{ + koanf: koanf.New(constDelimiter), + prefix: prefix, + delimiter: delimiter, + } +} + +// Name of the Source. +func (s SecretsSource) Name() (name string) { + return "secrets" +} + +// Merge the SecretsSource koanf.Koanf into the provided one. +func (s *SecretsSource) Merge(ko *koanf.Koanf, val *schema.StructValidator) (err error) { + for _, key := range s.koanf.Keys() { + value, ok := ko.Get(key).(string) + + if ok && value != "" { + val.Push(fmt.Errorf(errFmtSecretAlreadyDefined, key)) + } + } + + return ko.Merge(s.koanf) +} + +// Load the Source into the SecretsSource koanf.Koanf. +func (s *SecretsSource) Load(val *schema.StructValidator) (err error) { + keyMap := getSecretConfigMap(validator.ValidKeys, s.prefix, s.delimiter) + + return s.koanf.Load(env.ProviderWithValue(s.prefix, constDelimiter, koanfEnvironmentSecretsCallback(keyMap, val)), nil) +} + +// NewDefaultSources returns a slice of Source configured to load from specified YAML files. +func NewDefaultSources(filePaths []string, prefix, delimiter string) (sources []Source) { + fileSources := NewYAMLFileSources(filePaths) + for _, source := range fileSources { + sources = append(sources, source) + } + + sources = append(sources, NewEnvironmentSource(prefix, delimiter)) + sources = append(sources, NewSecretsSource(prefix, delimiter)) + + return sources +} diff --git a/internal/configuration/template.go b/internal/configuration/template.go new file mode 100644 index 00000000..f9b66110 --- /dev/null +++ b/internal/configuration/template.go @@ -0,0 +1,31 @@ +package configuration + +import ( + _ "embed" + "fmt" + "io/ioutil" + "os" +) + +//go:embed config.template.yml +var template []byte + +// EnsureConfigurationExists is an auxiliary function to the main Configuration tools that ensures the Configuration +// template is created if it doesn't already exist. +func EnsureConfigurationExists(path string) (created bool, err error) { + _, err = os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + err := ioutil.WriteFile(path, template, 0600) + if err != nil { + return false, fmt.Errorf(errFmtGenerateConfiguration, err) + } + + return true, nil + } + + return false, fmt.Errorf(errFmtGenerateConfiguration, err) + } + + return false, nil +} diff --git a/internal/configuration/template_test.go b/internal/configuration/template_test.go new file mode 100644 index 00000000..113d272a --- /dev/null +++ b/internal/configuration/template_test.go @@ -0,0 +1,59 @@ +package configuration + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authelia/authelia/internal/utils" +) + +func TestShouldGenerateConfiguration(t *testing.T) { + dir, err := ioutil.TempDir("", "authelia-config") + assert.NoError(t, err) + + cfg := filepath.Join(dir, "config.yml") + + created, err := EnsureConfigurationExists(cfg) + assert.NoError(t, err) + assert.True(t, created) + + _, err = os.Stat(cfg) + assert.NoError(t, err) +} + +func TestShouldNotGenerateConfigurationOnFSAccessDenied(t *testing.T) { + if runtime.GOOS == constWindows { + t.Skip("skipping test due to being on windows") + } + + dir, err := ioutil.TempDir("", "authelia-config") + assert.NoError(t, err) + + assert.NoError(t, os.Mkdir(filepath.Join(dir, "zero"), 0000)) + + cfg := filepath.Join(dir, "zero", "config.yml") + + created, err := EnsureConfigurationExists(cfg) + assert.EqualError(t, err, fmt.Sprintf("error occurred generating configuration: stat %s: permission denied", cfg)) + assert.False(t, created) +} + +func TestShouldNotGenerateConfiguration(t *testing.T) { + dir, err := ioutil.TempDir("", "authelia-config") + assert.NoError(t, err) + + cfg := filepath.Join(dir, "..", "not-a-dir", "config.yml") + + created, err := EnsureConfigurationExists(cfg) + + expectedErr := fmt.Sprintf(utils.GetExpectedErrTxt("pathnotfound"), cfg) + + assert.EqualError(t, err, fmt.Sprintf(errFmtGenerateConfiguration, expectedErr)) + assert.False(t, created) +} diff --git a/internal/configuration/test_resources/example_secret b/internal/configuration/test_resources/example_secret new file mode 100644 index 00000000..5cc2c947 --- /dev/null +++ b/internal/configuration/test_resources/example_secret @@ -0,0 +1 @@ +example_secret value diff --git a/internal/configuration/types.go b/internal/configuration/types.go new file mode 100644 index 00000000..68a49a72 --- /dev/null +++ b/internal/configuration/types.go @@ -0,0 +1,34 @@ +package configuration + +import ( + "github.com/knadh/koanf" + + "github.com/authelia/authelia/internal/configuration/schema" +) + +// Source is an abstract representation of a configuration Source implementation. +type Source interface { + Name() (name string) + Merge(ko *koanf.Koanf, val *schema.StructValidator) (err error) + Load(val *schema.StructValidator) (err error) +} + +// YAMLFileSource is a configuration Source with a YAML File. +type YAMLFileSource struct { + koanf *koanf.Koanf + path string +} + +// EnvironmentSource is a configuration Source which loads values from the environment. +type EnvironmentSource struct { + koanf *koanf.Koanf + prefix string + delimiter string +} + +// SecretsSource loads environment variables that have a value pointing to a file. +type SecretsSource struct { + koanf *koanf.Koanf + prefix string + delimiter string +} diff --git a/internal/configuration/validator/access_control.go b/internal/configuration/validator/access_control.go index 424d4124..3eae6f7f 100644 --- a/internal/configuration/validator/access_control.go +++ b/internal/configuration/validator/access_control.go @@ -12,7 +12,7 @@ import ( // IsPolicyValid check if policy is valid. func IsPolicyValid(policy string) (isValid bool) { - return policy == denyPolicy || policy == oneFactorPolicy || policy == twoFactorPolicy || policy == bypassPolicy + return policy == policyDeny || policy == policyOneFactor || policy == policyTwoFactor || policy == policyBypass } // IsResourceValid check if a resource is valid. @@ -52,7 +52,7 @@ func IsNetworkValid(network string) (isValid bool) { // ValidateAccessControl validates access control configuration. func ValidateAccessControl(configuration *schema.AccessControlConfiguration, validator *schema.StructValidator) { if configuration.DefaultPolicy == "" { - configuration.DefaultPolicy = denyPolicy + configuration.DefaultPolicy = policyDeny } if !IsPolicyValid(configuration.DefaultPolicy) { @@ -73,7 +73,7 @@ func ValidateAccessControl(configuration *schema.AccessControlConfiguration, val // ValidateRules validates an ACL Rule configuration. func ValidateRules(configuration schema.AccessControlConfiguration, validator *schema.StructValidator) { if configuration.Rules == nil || len(configuration.Rules) == 0 { - if configuration.DefaultPolicy != oneFactorPolicy && configuration.DefaultPolicy != twoFactorPolicy { + if configuration.DefaultPolicy != policyOneFactor && configuration.DefaultPolicy != policyTwoFactor { validator.Push(fmt.Errorf("Default Policy [%s] is invalid, access control rules must be provided or a policy must either be 'one_factor' or 'two_factor'", configuration.DefaultPolicy)) return @@ -103,7 +103,7 @@ func ValidateRules(configuration schema.AccessControlConfiguration, validator *s validateMethods(rulePosition, rule, validator) - if rule.Policy == bypassPolicy && len(rule.Subjects) != 0 { + if rule.Policy == policyBypass && len(rule.Subjects) != 0 { validator.Push(fmt.Errorf(errAccessControlInvalidPolicyWithSubjects, rulePosition, rule.Domains, rule.Subjects)) } } diff --git a/internal/configuration/validator/access_control_test.go b/internal/configuration/validator/access_control_test.go index 00c24cc3..5acaa874 100644 --- a/internal/configuration/validator/access_control_test.go +++ b/internal/configuration/validator/access_control_test.go @@ -18,7 +18,7 @@ type AccessControl struct { func (suite *AccessControl) SetupTest() { suite.validator = schema.NewStructValidator() - suite.configuration.DefaultPolicy = denyPolicy + suite.configuration.DefaultPolicy = policyDeny suite.configuration.Networks = schema.DefaultACLNetwork suite.configuration.Rules = schema.DefaultACLRule } @@ -71,7 +71,7 @@ func (suite *AccessControl) TestShouldRaiseErrorWithNoRulesDefined() { func (suite *AccessControl) TestShouldRaiseWarningWithNoRulesDefined() { suite.configuration.Rules = []schema.ACLRule{} - suite.configuration.DefaultPolicy = twoFactorPolicy + suite.configuration.DefaultPolicy = policyTwoFactor ValidateRules(suite.configuration, suite.validator) diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index 82bd48ca..946c907a 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -10,7 +10,7 @@ import ( "github.com/authelia/authelia/internal/utils" ) -// ValidateAuthenticationBackend validates and update authentication backend configuration. +// ValidateAuthenticationBackend validates and updates the authentication backend configuration. func ValidateAuthenticationBackend(configuration *schema.AuthenticationBackendConfiguration, validator *schema.StructValidator) { if configuration.LDAP == nil && configuration.File == nil { validator.Push(errors.New("Please provide `ldap` or `file` object in `authentication_backend`")) @@ -36,7 +36,7 @@ func ValidateAuthenticationBackend(configuration *schema.AuthenticationBackendCo } } -//nolint:gocyclo // TODO: Consider refactoring/simplifying, time permitting. +// validateFileAuthenticationBackend validates and updates the file authentication backend configuration. func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationBackendConfiguration, validator *schema.StructValidator) { if configuration.Path == "" { validator.Push(errors.New("Please provide a `path` for the users database in `authentication_backend`")) @@ -45,26 +45,6 @@ func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationB if configuration.Password == nil { configuration.Password = &schema.DefaultPasswordConfiguration } else { - if configuration.Password.Algorithm == "" { - configuration.Password.Algorithm = schema.DefaultPasswordConfiguration.Algorithm - } else { - configuration.Password.Algorithm = strings.ToLower(configuration.Password.Algorithm) - if configuration.Password.Algorithm != argon2id && configuration.Password.Algorithm != sha512 { - validator.Push(fmt.Errorf("Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured '%s'", configuration.Password.Algorithm)) - } - } - - // Iterations (time) - if configuration.Password.Iterations == 0 { - if configuration.Password.Algorithm == argon2id { - configuration.Password.Iterations = schema.DefaultPasswordConfiguration.Iterations - } else { - configuration.Password.Iterations = schema.DefaultPasswordSHA512Configuration.Iterations - } - } else if configuration.Password.Iterations < 1 { - validator.Push(fmt.Errorf("The number of iterations specified is invalid, must be 1 or more, you configured %d", configuration.Password.Iterations)) - } - // Salt Length switch { case configuration.Password.SaltLength == 0: @@ -73,28 +53,55 @@ func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationB validator.Push(fmt.Errorf("The salt length must be 2 or more, you configured %d", configuration.Password.SaltLength)) } - if configuration.Password.Algorithm == argon2id { - // Parallelism - if configuration.Password.Parallelism == 0 { - configuration.Password.Parallelism = schema.DefaultPasswordConfiguration.Parallelism - } else if configuration.Password.Parallelism < 1 { - validator.Push(fmt.Errorf("Parallelism for argon2id must be 1 or more, you configured %d", configuration.Password.Parallelism)) - } - - // Memory - if configuration.Password.Memory == 0 { - configuration.Password.Memory = schema.DefaultPasswordConfiguration.Memory - } else if configuration.Password.Memory < configuration.Password.Parallelism*8 { - validator.Push(fmt.Errorf("Memory for argon2id must be %d or more (parallelism * 8), you configured memory as %d and parallelism as %d", configuration.Password.Parallelism*8, configuration.Password.Memory, configuration.Password.Parallelism)) - } - - // Key Length - if configuration.Password.KeyLength == 0 { - configuration.Password.KeyLength = schema.DefaultPasswordConfiguration.KeyLength - } else if configuration.Password.KeyLength < 16 { - validator.Push(fmt.Errorf("Key length for argon2id must be 16, you configured %d", configuration.Password.KeyLength)) - } + switch configuration.Password.Algorithm { + case "": + configuration.Password.Algorithm = schema.DefaultPasswordConfiguration.Algorithm + fallthrough + case hashArgon2id: + validateFileAuthenticationBackendArgon2id(configuration, validator) + case hashSHA512: + validateFileAuthenticationBackendSHA512(configuration) + default: + validator.Push(fmt.Errorf("Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured '%s'", configuration.Password.Algorithm)) } + + if configuration.Password.Iterations < 1 { + validator.Push(fmt.Errorf("The number of iterations specified is invalid, must be 1 or more, you configured %d", configuration.Password.Iterations)) + } + } +} + +func validateFileAuthenticationBackendSHA512(configuration *schema.FileAuthenticationBackendConfiguration) { + // Iterations (time) + if configuration.Password.Iterations == 0 { + configuration.Password.Iterations = schema.DefaultPasswordSHA512Configuration.Iterations + } +} +func validateFileAuthenticationBackendArgon2id(configuration *schema.FileAuthenticationBackendConfiguration, validator *schema.StructValidator) { + // Iterations (time) + if configuration.Password.Iterations == 0 { + configuration.Password.Iterations = schema.DefaultPasswordConfiguration.Iterations + } + + // Parallelism + if configuration.Password.Parallelism == 0 { + configuration.Password.Parallelism = schema.DefaultPasswordConfiguration.Parallelism + } else if configuration.Password.Parallelism < 1 { + validator.Push(fmt.Errorf("Parallelism for argon2id must be 1 or more, you configured %d", configuration.Password.Parallelism)) + } + + // Memory + if configuration.Password.Memory == 0 { + configuration.Password.Memory = schema.DefaultPasswordConfiguration.Memory + } else if configuration.Password.Memory < configuration.Password.Parallelism*8 { + validator.Push(fmt.Errorf("Memory for argon2id must be %d or more (parallelism * 8), you configured memory as %d and parallelism as %d", configuration.Password.Parallelism*8, configuration.Password.Memory, configuration.Password.Parallelism)) + } + + // Key Length + if configuration.Password.KeyLength == 0 { + configuration.Password.KeyLength = schema.DefaultPasswordConfiguration.KeyLength + } else if configuration.Password.KeyLength < 16 { + validator.Push(fmt.Errorf("Key length for argon2id must be 16, you configured %d", configuration.Password.KeyLength)) } } diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index f38ea9c1..d135b811 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -97,7 +97,7 @@ func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWh ValidateAuthenticationBackend(&suite.configuration, suite.validator) suite.Assert().False(suite.validator.HasWarnings()) - suite.Assert().False(suite.validator.HasErrors()) + suite.Assert().Len(suite.validator.Errors(), 0) suite.Assert().Equal(schema.DefaultPasswordConfiguration.KeyLength, suite.configuration.File.Password.KeyLength) suite.Assert().Equal(schema.DefaultPasswordConfiguration.Iterations, suite.configuration.File.Password.Iterations) @@ -115,7 +115,7 @@ func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWh ValidateAuthenticationBackend(&suite.configuration, suite.validator) suite.Assert().False(suite.validator.HasWarnings()) - suite.Assert().False(suite.validator.HasErrors()) + suite.Assert().Len(suite.validator.Errors(), 0) suite.Assert().Equal(schema.DefaultPasswordSHA512Configuration.KeyLength, suite.configuration.File.Password.KeyLength) suite.Assert().Equal(schema.DefaultPasswordSHA512Configuration.Iterations, suite.configuration.File.Password.Iterations) diff --git a/internal/configuration/validator/configuration_test.go b/internal/configuration/validator/configuration_test.go index 071cf565..7275c8a4 100644 --- a/internal/configuration/validator/configuration_test.go +++ b/internal/configuration/validator/configuration_test.go @@ -14,8 +14,8 @@ func newDefaultConfig() schema.Configuration { config := schema.Configuration{} config.Server.Host = loopback config.Server.Port = 9090 - config.Logging.Level = "info" - config.Logging.Format = "text" + config.Log.Level = "info" + config.Log.Format = "text" config.JWTSecret = testJWTSecret config.AuthenticationBackend.File = &schema.FileAuthenticationBackendConfiguration{ Path: "/a/path", @@ -81,7 +81,10 @@ func TestShouldRaiseErrorWithUndefinedJWTSecretKey(t *testing.T) { ValidateConfiguration(&config, validator) require.Len(t, validator.Errors(), 1) + require.Len(t, validator.Warnings(), 1) + assert.EqualError(t, validator.Errors()[0], "Provide a JWT secret using \"jwt_secret\" key") + assert.EqualError(t, validator.Warnings()[0], "No access control rules have been defined so the default policy two_factor will be applied to all requests") } func TestShouldRaiseErrorWithBadDefaultRedirectionURL(t *testing.T) { @@ -91,16 +94,24 @@ func TestShouldRaiseErrorWithBadDefaultRedirectionURL(t *testing.T) { ValidateConfiguration(&config, validator) require.Len(t, validator.Errors(), 1) + require.Len(t, validator.Warnings(), 1) + assert.EqualError(t, validator.Errors()[0], "Value for \"default_redirection_url\" is invalid: the url 'bad_default_redirection_url' is not absolute because it doesn't start with a scheme like 'http://' or 'https://'") + assert.EqualError(t, validator.Warnings()[0], "No access control rules have been defined so the default policy two_factor will be applied to all requests") } func TestShouldNotOverrideCertificatesDirectoryAndShouldPassWhenBlank(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultConfig() + ValidateConfiguration(&config, validator) - require.Len(t, validator.Errors(), 0) + + assert.Len(t, validator.Errors(), 0) + require.Len(t, validator.Warnings(), 1) require.Equal(t, "", config.CertificatesDirectory) + + assert.EqualError(t, validator.Warnings()[0], "No access control rules have been defined so the default policy two_factor will be applied to all requests") } func TestShouldRaiseErrorOnInvalidCertificatesDirectory(t *testing.T) { @@ -111,6 +122,7 @@ func TestShouldRaiseErrorOnInvalidCertificatesDirectory(t *testing.T) { ValidateConfiguration(&config, validator) require.Len(t, validator.Errors(), 1) + require.Len(t, validator.Warnings(), 1) if runtime.GOOS == "windows" { assert.EqualError(t, validator.Errors()[0], "Error checking certificate directory: CreateFile not-a-real-file.go: The system cannot find the file specified.") @@ -118,12 +130,18 @@ func TestShouldRaiseErrorOnInvalidCertificatesDirectory(t *testing.T) { assert.EqualError(t, validator.Errors()[0], "Error checking certificate directory: stat not-a-real-file.go: no such file or directory") } + assert.EqualError(t, validator.Warnings()[0], "No access control rules have been defined so the default policy two_factor will be applied to all requests") + validator = schema.NewStructValidator() config.CertificatesDirectory = "const.go" + ValidateConfiguration(&config, validator) require.Len(t, validator.Errors(), 1) + require.Len(t, validator.Warnings(), 1) + assert.EqualError(t, validator.Errors()[0], "The path const.go specified for certificate_directory is not a directory") + assert.EqualError(t, validator.Warnings()[0], "No access control rules have been defined so the default policy two_factor will be applied to all requests") } func TestShouldNotRaiseErrorOnValidCertificatesDirectory(t *testing.T) { @@ -133,5 +151,8 @@ func TestShouldNotRaiseErrorOnValidCertificatesDirectory(t *testing.T) { ValidateConfiguration(&config, validator) - require.Len(t, validator.Errors(), 0) + assert.Len(t, validator.Errors(), 0) + require.Len(t, validator.Warnings(), 1) + + assert.EqualError(t, validator.Warnings()[0], "No access control rules have been defined so the default policy two_factor will be applied to all requests") } diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 13feb769..e15420db 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -1,65 +1,36 @@ package validator +import "regexp" + const ( loopback = "127.0.0.1" oauth2InstalledApp = "urn:ietf:wg:oauth:2.0:oob" ) +// Policy constants. const ( - errFmtDeprecatedConfigurationKey = "[DEPRECATED] The %s configuration option is deprecated and will be " + - "removed in %s, please use %s instead" - errFmtReplacedConfigurationKey = "invalid configuration key '%s' was replaced by '%s'" + policyBypass = "bypass" + policyOneFactor = "one_factor" + policyTwoFactor = "two_factor" + policyDeny = "deny" +) - errFmtLoggingLevelInvalid = "the log level '%s' is invalid, must be one of: %s" - - errFmtSessionSecretRedisProvider = "The session secret must be set when using the %s session provider" - errFmtSessionRedisPortRange = "The port must be between 1 and 65535 for the %s session provider" - errFmtSessionRedisHostRequired = "The host must be provided when using the %s session provider" - errFmtSessionRedisHostOrNodesRequired = "Either the host or a node must be provided when using the %s session provider" - - errFmtOIDCServerClientRedirectURI = "OIDC client with ID '%s' redirect URI %s has an invalid scheme '%s', " + - "should be http or https" - errFmtOIDCClientRedirectURIPublic = "openid connect provider: client with ID '%s' redirect URI '%s' is " + - "only valid for the public client type, not the confidential client type" - errFmtOIDCClientRedirectURIAbsolute = "openid connect provider: client with ID '%s' redirect URI '%s' is invalid " + - "because it has no scheme when it should be http or https" - errFmtOIDCServerClientRedirectURICantBeParsed = "OIDC client with ID '%s' has an invalid redirect URI '%s' " + - "could not be parsed: %v" - errFmtOIDCServerClientInvalidPolicy = "OIDC client with ID '%s' has an invalid policy '%s', " + - "should be either 'one_factor' or 'two_factor'" - errFmtOIDCServerClientInvalidSecret = "OIDC client with ID '%s' has an empty secret" //nolint:gosec - errFmtOIDCClientPublicInvalidSecret = "openid connect provider: client with ID '%s' is public but does not have an empty secret" //nolint:gosec - errFmtOIDCServerClientInvalidScope = "OIDC client with ID '%s' has an invalid scope '%s', " + - "must be one of: '%s'" - errFmtOIDCServerClientInvalidGrantType = "OIDC client with ID '%s' has an invalid grant type '%s', " + - "must be one of: '%s'" - errFmtOIDCServerClientInvalidResponseMode = "OIDC client with ID '%s' has an invalid response mode '%s', " + - "must be one of: '%s'" - errFmtOIDCServerClientInvalidUserinfoAlgorithm = "OIDC client with ID '%s' has an invalid userinfo signing " + - "algorithm '%s', must be one of: '%s'" - errFmtOIDCServerInsecureParameterEntropy = "SECURITY ISSUE: OIDC minimum parameter entropy is configured to an " + - "unsafe value, it should be above 8 but it's configured to %d." - - errFileHashing = "config key incorrect: authentication_backend.file.hashing should be " + - "authentication_backend.file.password" - errFilePHashing = "config key incorrect: authentication_backend.file.password_hashing should be " + - "authentication_backend.file.password" - errFilePOptions = "config key incorrect: authentication_backend.file.password_options should be " + - "authentication_backend.file.password" - - bypassPolicy = "bypass" - oneFactorPolicy = "one_factor" - twoFactorPolicy = "two_factor" - denyPolicy = "deny" - - argon2id = "argon2id" - sha512 = "sha512" +// Hashing constants. +const ( + hashArgon2id = "argon2id" + hashSHA512 = "sha512" +) +// Scheme constants. +const ( schemeLDAP = "ldap" schemeLDAPS = "ldaps" schemeHTTP = "http" schemeHTTPS = "https" +) +// Test constants. +const ( testBadTimer = "-1" testInvalidPolicy = "invalid" testJWTSecret = "a_secret" @@ -70,9 +41,58 @@ const ( testModeDisabled = "disable" testTLSCert = "/tmp/cert.pem" testTLSKey = "/tmp/key.pem" +) - errAccessControlInvalidPolicyWithSubjects = "Policy [bypass] for rule #%d domain %s with subjects %s is invalid. " + - "It is not supported to configure both policy bypass and subjects. For more information see: " + +// OpenID Error constants. +const ( + errFmtOIDCClientsDuplicateID = "openid connect provider: one or more clients have the same ID" + errFmtOIDCClientsWithEmptyID = "openid connect provider: one or more clients have been configured with an empty ID" + errFmtOIDCNoClientsConfigured = "openid connect provider: no clients are configured" + errFmtOIDCNoPrivateKey = "openid connect provider: issuer private key must be provided" + errFmtOIDCClientInvalidSecret = "openid connect provider: client with ID '%s' has an empty secret" + errFmtOIDCClientPublicInvalidSecret = "openid connect provider: client with ID '%s' is public but does not have " + + "an empty secret" + errFmtOIDCClientRedirectURI = "openid connect provider: client with ID '%s' redirect URI %s has an " + + "invalid scheme %s, should be http or https" + errFmtOIDCClientRedirectURICantBeParsed = "openid connect provider: client with ID '%s' has an invalid redirect " + + "URI '%s' could not be parsed: %v" + errFmtOIDCClientRedirectURIPublic = "openid connect provider: client with ID '%s' redirect URI '%s' is " + + "only valid for the public client type, not the confidential client type" + errFmtOIDCClientRedirectURIAbsolute = "openid connect provider: client with ID '%s' redirect URI '%s' is invalid " + + "because it has no scheme when it should be http or https" + errFmtOIDCClientInvalidPolicy = "openid connect provider: client with ID '%s' has an invalid policy " + + "'%s', should be either 'one_factor' or 'two_factor'" + errFmtOIDCClientInvalidScope = "openid connect provider: client with ID '%s' has an invalid scope " + + "'%s', must be one of: '%s'" + errFmtOIDCClientInvalidGrantType = "openid connect provider: client with ID '%s' has an invalid grant type " + + "'%s', must be one of: '%s'" + errFmtOIDCClientInvalidResponseMode = "openid connect provider: client with ID '%s' has an invalid response mode " + + "'%s', must be one of: '%s'" + errFmtOIDCClientInvalidUserinfoAlgorithm = "openid connect provider: client with ID '%s' has an invalid userinfo signing " + + "algorithm '%s', must be one of: '%s'" + errFmtOIDCServerInsecureParameterEntropy = "openid connect provider: SECURITY ISSUE - minimum parameter entropy is " + + "configured to an unsafe value, it should be above 8 but it's configured to %d" +) + +// Error constants. +const ( + errFmtDeprecatedConfigurationKey = "the %s configuration option is deprecated and will be " + + "removed in %s, please use %s instead" + errFmtReplacedConfigurationKey = "invalid configuration key '%s' was replaced by '%s'" + + errFmtLoggingLevelInvalid = "the log level '%s' is invalid, must be one of: %s" + + errFmtSessionSecretRedisProvider = "the session secret must be set when using the %s session provider" + errFmtSessionRedisPortRange = "the port must be between 1 and 65535 for the %s session provider" + errFmtSessionRedisHostRequired = "the host must be provided when using the %s session provider" + errFmtSessionRedisHostOrNodesRequired = "either the host or a node must be provided when using the %s session provider" + + errFileHashing = "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password" + errFilePHashing = "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password" + errFilePOptions = "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password" + + errAccessControlInvalidPolicyWithSubjects = "policy [bypass] for rule #%d domain %s with subjects %s is invalid. It is " + + "not supported to configure both policy bypass and subjects. For more information see: " + "https://www.authelia.com/docs/configuration/access-control.html#combining-subjects-and-the-bypass-policy" ) @@ -84,28 +104,16 @@ var validOIDCGrantTypes = []string{"implicit", "refresh_token", "authorization_c var validOIDCResponseModes = []string{"form_post", "query", "fragment"} var validOIDCUserinfoAlgorithms = []string{"none", "RS256"} -// SecretNames contains a map of secret names. -var SecretNames = map[string]string{ - "JWTSecret": "jwt_secret", - "SessionSecret": "session.secret", - "DUOSecretKey": "duo_api.secret_key", - "RedisPassword": "session.redis.password", - "RedisSentinelPassword": "session.redis.high_availability.sentinel_password", - "LDAPPassword": "authentication_backend.ldap.password", - "SMTPPassword": "notifier.smtp.password", - "MySQLPassword": "storage.mysql.password", - "PostgreSQLPassword": "storage.postgres.password", - "OpenIDConnectHMACSecret": "identity_providers.oidc.hmac_secret", - "OpenIDConnectIssuerPrivateKey": "identity_providers.oidc.issuer_private_key", -} +var reKeyReplacer = regexp.MustCompile(`\[\d+]`) -// validKeys is a list of valid keys that are not secret names. For the sake of consistency please place any secret in +// ValidKeys is a list of valid keys that are not secret names. For the sake of consistency please place any secret in // the secret names map and reuse it in relevant sections. -var validKeys = []string{ +var ValidKeys = []string{ // Root Keys. "certificates_directory", "theme", "default_redirection_url", + "jwt_secret", // Log keys. "log.level", @@ -139,14 +147,26 @@ var validKeys = []string{ "totp.period", "totp.skew", + // DUO API Keys. + "duo_api.hostname", + "duo_api.secret_key", + "duo_api.integration_key", + // Access Control Keys. - "access_control.rules", "access_control.default_policy", "access_control.networks", + "access_control.rules", + "access_control.rules[].domain", + "access_control.rules[].methods", + "access_control.rules[].networks", + "access_control.rules[].subject", + "access_control.rules[].policy", + "access_control.rules[].resources", // Session Keys. "session.name", "session.domain", + "session.secret", "session.same_site", "session.expiration", "session.inactivity", @@ -156,6 +176,7 @@ var validKeys = []string{ "session.redis.host", "session.redis.port", "session.redis.username", + "session.redis.password", "session.redis.database_index", "session.redis.maximum_active_connections", "session.redis.minimum_idle_connections", @@ -163,6 +184,7 @@ var validKeys = []string{ "session.redis.tls.skip_verify", "session.redis.tls.server_name", "session.redis.high_availability.sentinel_name", + "session.redis.high_availability.sentinel_password", "session.redis.high_availability.nodes", "session.redis.high_availability.route_by_latency", "session.redis.high_availability.route_randomly", @@ -180,12 +202,14 @@ var validKeys = []string{ "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. @@ -193,9 +217,10 @@ var validKeys = []string{ "notifier.disable_startup_check", // SMTP Notifier Keys. - "notifier.smtp.username", "notifier.smtp.host", "notifier.smtp.port", + "notifier.smtp.username", + "notifier.smtp.password", "notifier.smtp.identifier", "notifier.smtp.sender", "notifier.smtp.subject", @@ -211,10 +236,6 @@ var validKeys = []string{ "regulation.find_time", "regulation.ban_time", - // DUO API Keys. - "duo_api.hostname", - "duo_api.integration_key", - // Authentication Backend Keys. "authentication_backend.disable_reset_password", "authentication_backend.refresh_interval", @@ -232,6 +253,7 @@ var validKeys = []string{ "authentication_backend.ldap.mail_attribute", "authentication_backend.ldap.display_name_attribute", "authentication_backend.ldap.user", + "authentication_backend.ldap.password", "authentication_backend.ldap.start_tls", "authentication_backend.ldap.tls.minimum_version", "authentication_backend.ldap.tls.skip_verify", @@ -247,12 +269,22 @@ var validKeys = []string{ "authentication_backend.file.password.parallelism", // Identity Provider Keys. - "identity_providers.oidc.clients", + "identity_providers.oidc.hmac_secret", + "identity_providers.oidc.issuer_private_key", "identity_providers.oidc.id_token_lifespan", "identity_providers.oidc.access_token_lifespan", "identity_providers.oidc.refresh_token_lifespan", "identity_providers.oidc.authorize_code_lifespan", "identity_providers.oidc.enable_client_debug_messages", + "identity_providers.oidc.clients", + "identity_providers.oidc.clients[].id", + "identity_providers.oidc.clients[].description", + "identity_providers.oidc.clients[].secret", + "identity_providers.oidc.clients[].redirect_uris", + "identity_providers.oidc.clients[].authorization_policy", + "identity_providers.oidc.clients[].scopes", + "identity_providers.oidc.clients[].grant_types", + "identity_providers.oidc.clients[].response_types", } var replacedKeys = map[string]string{ @@ -265,8 +297,8 @@ var replacedKeys = map[string]string{ var specificErrorKeys = map[string]string{ "google_analytics": "config key removed: google_analytics - this functionality has been deprecated", - "notifier.smtp.trusted_cert": "invalid configuration key `notifier.smtp.trusted_cert` it has been removed, " + - "option has been replaced by the global option `certificates_directory`", + "notifier.smtp.trusted_cert": "invalid configuration key 'notifier.smtp.trusted_cert' it has been removed, " + + "option has been replaced by the global option 'certificates_directory'", "authentication_backend.file.password_options.algorithm": errFilePOptions, "authentication_backend.file.password_options.iterations": errFilePOptions, diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index 736b01cf..525b86cf 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -18,7 +18,7 @@ func ValidateIdentityProviders(configuration *schema.IdentityProvidersConfigurat func validateOIDC(configuration *schema.OpenIDConnectConfiguration, validator *schema.StructValidator) { if configuration != nil { if configuration.IssuerPrivateKey == "" { - validator.Push(fmt.Errorf("OIDC Server issuer private key must be provided")) + validator.Push(fmt.Errorf(errFmtOIDCNoPrivateKey)) } if configuration.AccessTokenLifespan == time.Duration(0) { @@ -44,7 +44,7 @@ func validateOIDC(configuration *schema.OpenIDConnectConfiguration, validator *s validateOIDCClients(configuration, validator) if len(configuration.Clients) == 0 { - validator.Push(fmt.Errorf("OIDC Server has no clients defined")) + validator.Push(fmt.Errorf(errFmtOIDCNoClientsConfigured)) } } } @@ -74,14 +74,14 @@ func validateOIDCClients(configuration *schema.OpenIDConnectConfiguration, valid } } else { if client.Secret == "" { - validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidSecret, client.ID)) + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidSecret, client.ID)) } } if client.Policy == "" { configuration.Clients[c].Policy = schema.DefaultOpenIDConnectClientConfiguration.Policy - } else if client.Policy != oneFactorPolicy && client.Policy != twoFactorPolicy { - validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidPolicy, client.ID, client.Policy)) + } else if client.Policy != policyOneFactor && client.Policy != policyTwoFactor { + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidPolicy, client.ID, client.Policy)) } validateOIDCClientScopes(c, configuration, validator) @@ -94,11 +94,11 @@ func validateOIDCClients(configuration *schema.OpenIDConnectConfiguration, valid } if invalidID { - validator.Push(fmt.Errorf("OIDC Server has one or more clients with an empty ID")) + validator.Push(fmt.Errorf(errFmtOIDCClientsWithEmptyID)) } if duplicateIDs { - validator.Push(fmt.Errorf("OIDC Server has clients with duplicate ID's")) + validator.Push(fmt.Errorf(errFmtOIDCClientsDuplicateID)) } } @@ -115,7 +115,7 @@ func validateOIDCClientScopes(c int, configuration *schema.OpenIDConnectConfigur for _, scope := range configuration.Clients[c].Scopes { if !utils.IsStringInSlice(scope, validOIDCScopes) { validator.Push(fmt.Errorf( - errFmtOIDCServerClientInvalidScope, + errFmtOIDCClientInvalidScope, configuration.Clients[c].ID, scope, strings.Join(validOIDCScopes, "', '"))) } } @@ -130,7 +130,7 @@ func validateOIDCClientGrantTypes(c int, configuration *schema.OpenIDConnectConf for _, grantType := range configuration.Clients[c].GrantTypes { if !utils.IsStringInSlice(grantType, validOIDCGrantTypes) { validator.Push(fmt.Errorf( - errFmtOIDCServerClientInvalidGrantType, + errFmtOIDCClientInvalidGrantType, configuration.Clients[c].ID, grantType, strings.Join(validOIDCGrantTypes, "', '"))) } } @@ -152,7 +152,7 @@ func validateOIDCClientResponseModes(c int, configuration *schema.OpenIDConnectC for _, responseMode := range configuration.Clients[c].ResponseModes { if !utils.IsStringInSlice(responseMode, validOIDCResponseModes) { validator.Push(fmt.Errorf( - errFmtOIDCServerClientInvalidResponseMode, + errFmtOIDCClientInvalidResponseMode, configuration.Clients[c].ID, responseMode, strings.Join(validOIDCResponseModes, "', '"))) } } @@ -162,7 +162,7 @@ func validateOIDDClientUserinfoAlgorithm(c int, configuration *schema.OpenIDConn if configuration.Clients[c].UserinfoSigningAlgorithm == "" { configuration.Clients[c].UserinfoSigningAlgorithm = schema.DefaultOpenIDConnectClientConfiguration.UserinfoSigningAlgorithm } else if !utils.IsStringInSlice(configuration.Clients[c].UserinfoSigningAlgorithm, validOIDCUserinfoAlgorithms) { - validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidUserinfoAlgorithm, + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidUserinfoAlgorithm, configuration.Clients[c].ID, configuration.Clients[c].UserinfoSigningAlgorithm, strings.Join(validOIDCUserinfoAlgorithms, ", "))) } } @@ -181,7 +181,7 @@ func validateOIDCClientRedirectURIs(client schema.OpenIDConnectClientConfigurati parsedURL, err := url.Parse(redirectURI) if err != nil { - validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURICantBeParsed, client.ID, redirectURI, err)) + validator.Push(fmt.Errorf(errFmtOIDCClientRedirectURICantBeParsed, client.ID, redirectURI, err)) continue } @@ -191,7 +191,7 @@ func validateOIDCClientRedirectURIs(client schema.OpenIDConnectClientConfigurati } if parsedURL.Scheme != schemeHTTPS && parsedURL.Scheme != schemeHTTP { - validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURI, client.ID, redirectURI, parsedURL.Scheme)) + validator.Push(fmt.Errorf(errFmtOIDCClientRedirectURI, client.ID, redirectURI, parsedURL.Scheme)) } } } diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index ac51ca40..934810ab 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -25,8 +25,8 @@ func TestShouldRaiseErrorWhenInvalidOIDCServerConfiguration(t *testing.T) { require.Len(t, validator.Errors(), 2) - assert.EqualError(t, validator.Errors()[0], "OIDC Server issuer private key must be provided") - assert.EqualError(t, validator.Errors()[1], "OIDC Server has no clients defined") + assert.EqualError(t, validator.Errors()[0], errFmtOIDCNoPrivateKey) + assert.EqualError(t, validator.Errors()[1], errFmtOIDCNoClientsConfigured) } func TestShouldRaiseErrorWhenOIDCServerIssuerPrivateKeyPathInvalid(t *testing.T) { @@ -42,7 +42,7 @@ func TestShouldRaiseErrorWhenOIDCServerIssuerPrivateKeyPathInvalid(t *testing.T) require.Len(t, validator.Errors(), 1) - assert.EqualError(t, validator.Errors()[0], "OIDC Server has no clients defined") + assert.EqualError(t, validator.Errors()[0], errFmtOIDCNoClientsConfigured) } func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { @@ -79,7 +79,7 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { { ID: "client-check-uri-parse", Secret: "a-secret", - Policy: twoFactorPolicy, + Policy: policyTwoFactor, RedirectURIs: []string{ "http://abc@%two", }, @@ -87,7 +87,7 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { { ID: "client-check-uri-abs", Secret: "a-secret", - Policy: twoFactorPolicy, + Policy: policyTwoFactor, RedirectURIs: []string{ "google.com", }, @@ -101,14 +101,14 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { require.Len(t, validator.Errors(), 8) assert.Equal(t, schema.DefaultOpenIDConnectClientConfiguration.Policy, config.OIDC.Clients[0].Policy) - assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtOIDCServerClientInvalidSecret, "")) - assert.EqualError(t, validator.Errors()[1], fmt.Sprintf(errFmtOIDCServerClientRedirectURI, "", "tcp://google.com", "tcp")) - assert.EqualError(t, validator.Errors()[2], fmt.Sprintf(errFmtOIDCServerClientInvalidPolicy, "a-client", "a-policy")) - assert.EqualError(t, validator.Errors()[3], fmt.Sprintf(errFmtOIDCServerClientInvalidPolicy, "a-client", "a-policy")) - assert.EqualError(t, validator.Errors()[4], fmt.Sprintf(errFmtOIDCServerClientRedirectURICantBeParsed, "client-check-uri-parse", "http://abc@%two", errors.New("parse \"http://abc@%two\": invalid URL escape \"%tw\""))) + assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtOIDCClientInvalidSecret, "")) + assert.EqualError(t, validator.Errors()[1], fmt.Sprintf(errFmtOIDCClientRedirectURI, "", "tcp://google.com", "tcp")) + assert.EqualError(t, validator.Errors()[2], fmt.Sprintf(errFmtOIDCClientInvalidPolicy, "a-client", "a-policy")) + assert.EqualError(t, validator.Errors()[3], fmt.Sprintf(errFmtOIDCClientInvalidPolicy, "a-client", "a-policy")) + assert.EqualError(t, validator.Errors()[4], fmt.Sprintf(errFmtOIDCClientRedirectURICantBeParsed, "client-check-uri-parse", "http://abc@%two", errors.New("parse \"http://abc@%two\": invalid URL escape \"%tw\""))) assert.EqualError(t, validator.Errors()[5], fmt.Sprintf(errFmtOIDCClientRedirectURIAbsolute, "client-check-uri-abs", "google.com")) - assert.EqualError(t, validator.Errors()[6], "OIDC Server has one or more clients with an empty ID") - assert.EqualError(t, validator.Errors()[7], "OIDC Server has clients with duplicate ID's") + assert.EqualError(t, validator.Errors()[6], errFmtOIDCClientsWithEmptyID) + assert.EqualError(t, validator.Errors()[7], errFmtOIDCClientsDuplicateID) } func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadScopes(t *testing.T) { @@ -134,7 +134,7 @@ func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadScopes(t *testing.T) { ValidateIdentityProviders(config, validator) require.Len(t, validator.Errors(), 1) - assert.EqualError(t, validator.Errors()[0], "OIDC client with ID 'good_id' has an invalid scope "+ + assert.EqualError(t, validator.Errors()[0], "openid connect provider: client with ID 'good_id' has an invalid scope "+ "'bad_scope', must be one of: 'openid', 'email', 'profile', 'groups', 'offline_access'") } @@ -161,7 +161,7 @@ func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadGrantTypes(t *testing.T) ValidateIdentityProviders(config, validator) require.Len(t, validator.Errors(), 1) - assert.EqualError(t, validator.Errors()[0], "OIDC client with ID 'good_id' has an invalid grant type "+ + assert.EqualError(t, validator.Errors()[0], "openid connect provider: client with ID 'good_id' has an invalid grant type "+ "'bad_grant_type', must be one of: 'implicit', 'refresh_token', 'authorization_code', "+ "'password', 'client_credentials'") } @@ -189,7 +189,7 @@ func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadResponseModes(t *testing ValidateIdentityProviders(config, validator) require.Len(t, validator.Errors(), 1) - assert.EqualError(t, validator.Errors()[0], "OIDC client with ID 'good_id' has an invalid response mode "+ + assert.EqualError(t, validator.Errors()[0], "openid connect provider: client with ID 'good_id' has an invalid response mode "+ "'bad_responsemode', must be one of: 'form_post', 'query', 'fragment'") } @@ -216,7 +216,7 @@ func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadUserinfoAlg(t *testing.T ValidateIdentityProviders(config, validator) require.Len(t, validator.Errors(), 1) - assert.EqualError(t, validator.Errors()[0], "OIDC client with ID 'good_id' has an invalid userinfo "+ + assert.EqualError(t, validator.Errors()[0], "openid connect provider: client with ID 'good_id' has an invalid userinfo "+ "signing algorithm 'rs256', must be one of: 'none, RS256'") } @@ -245,7 +245,7 @@ func TestValidateIdentityProvidersShouldRaiseWarningOnSecurityIssue(t *testing.T assert.Len(t, validator.Errors(), 0) require.Len(t, validator.Warnings(), 1) - assert.EqualError(t, validator.Warnings()[0], "SECURITY ISSUE: OIDC minimum parameter entropy is configured to an unsafe value, it should be above 8 but it's configured to 1.") + assert.EqualError(t, validator.Warnings()[0], "openid connect provider: SECURITY ISSUE - minimum parameter entropy is configured to an unsafe value, it should be above 8 but it's configured to 1") } func TestValidateIdentityProvidersShouldRaiseErrorsOnInvalidClientTypes(t *testing.T) { @@ -345,7 +345,7 @@ func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) { ID: "b-client", Description: "Normal Description", Secret: "b-client-secret", - Policy: oneFactorPolicy, + Policy: policyOneFactor, UserinfoSigningAlgorithm: "RS256", RedirectURIs: []string{ "https://google.com", @@ -375,11 +375,11 @@ func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) { assert.Len(t, validator.Errors(), 0) // Assert Clients[0] Policy is set to the default, and the default doesn't override Clients[1]'s Policy. - assert.Equal(t, config.OIDC.Clients[0].Policy, twoFactorPolicy) - assert.Equal(t, config.OIDC.Clients[1].Policy, oneFactorPolicy) + assert.Equal(t, policyTwoFactor, config.OIDC.Clients[0].Policy) + assert.Equal(t, policyOneFactor, config.OIDC.Clients[1].Policy) - assert.Equal(t, config.OIDC.Clients[0].UserinfoSigningAlgorithm, "none") - assert.Equal(t, config.OIDC.Clients[1].UserinfoSigningAlgorithm, "RS256") + assert.Equal(t, "none", config.OIDC.Clients[0].UserinfoSigningAlgorithm) + assert.Equal(t, "RS256", config.OIDC.Clients[1].UserinfoSigningAlgorithm) // Assert Clients[0] Description is set to the Clients[0] ID, and Clients[1]'s Description is not overridden. assert.Equal(t, config.OIDC.Clients[0].ID, config.OIDC.Clients[0].Description) diff --git a/internal/configuration/validator/keys.go b/internal/configuration/validator/keys.go index 900170f9..bc73f7cf 100644 --- a/internal/configuration/validator/keys.go +++ b/internal/configuration/validator/keys.go @@ -3,35 +3,38 @@ package validator import ( "errors" "fmt" + "strings" "github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/utils" ) -// ValidateKeys determines if a provided key is valid. -func ValidateKeys(validator *schema.StructValidator, keys []string) { +// ValidateKeys determines if all provided keys are valid. +func ValidateKeys(keys []string, prefix string, validator *schema.StructValidator) { var errStrings []string for _, key := range keys { - if utils.IsStringInSlice(key, validKeys) { + expectedKey := reKeyReplacer.ReplaceAllString(key, "[]") + + if utils.IsStringInSlice(expectedKey, ValidKeys) { continue } - if isSecretKey(key) { - continue - } - - if newKey, ok := replacedKeys[key]; ok { + if newKey, ok := replacedKeys[expectedKey]; ok { validator.Push(fmt.Errorf(errFmtReplacedConfigurationKey, key, newKey)) continue } - if err, ok := specificErrorKeys[key]; ok { + if err, ok := specificErrorKeys[expectedKey]; ok { if !utils.IsStringInSlice(err, errStrings) { errStrings = append(errStrings, err) } } else { - validator.Push(fmt.Errorf("config key not expected: %s", key)) + if strings.HasPrefix(key, prefix) { + validator.PushWarning(fmt.Errorf("configuration environment variable not expected: %s", key)) + } else { + validator.Push(fmt.Errorf("configuration key not expected: %s", key)) + } } } diff --git a/internal/configuration/validator/keys_test.go b/internal/configuration/validator/keys_test.go index c34812c1..179dbac3 100644 --- a/internal/configuration/validator/keys_test.go +++ b/internal/configuration/validator/keys_test.go @@ -12,25 +12,41 @@ import ( ) func TestShouldValidateGoodKeys(t *testing.T) { - configKeys := validKeys + configKeys := ValidKeys val := schema.NewStructValidator() - ValidateKeys(val, configKeys) + ValidateKeys(configKeys, "AUTHELIA_", val) require.Len(t, val.Errors(), 0) } func TestShouldNotValidateBadKeys(t *testing.T) { - configKeys := validKeys + configKeys := ValidKeys configKeys = append(configKeys, "bad_key") configKeys = append(configKeys, "totp.skewy") val := schema.NewStructValidator() - ValidateKeys(val, configKeys) + ValidateKeys(configKeys, "AUTHELIA_", val) 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") + assert.EqualError(t, errs[0], "configuration key not expected: bad_key") + assert.EqualError(t, errs[1], "configuration key not expected: totp.skewy") +} + +func TestShouldNotValidateBadEnvKeys(t *testing.T) { + configKeys := ValidKeys + configKeys = append(configKeys, "AUTHELIA__BAD_ENV_KEY") + configKeys = append(configKeys, "AUTHELIA_BAD_ENV_KEY") + + val := schema.NewStructValidator() + ValidateKeys(configKeys, "AUTHELIA_", val) + + warns := val.Warnings() + assert.Len(t, val.Errors(), 0) + require.Len(t, warns, 2) + + assert.EqualError(t, warns[0], "configuration environment variable not expected: AUTHELIA__BAD_ENV_KEY") + assert.EqualError(t, warns[1], "configuration environment variable not expected: AUTHELIA_BAD_ENV_KEY") } func TestAllSpecificErrorKeys(t *testing.T) { @@ -48,7 +64,7 @@ func TestAllSpecificErrorKeys(t *testing.T) { } val := schema.NewStructValidator() - ValidateKeys(val, configKeys) + ValidateKeys(configKeys, "AUTHELIA_", val) errs := val.Errors() @@ -72,7 +88,7 @@ func TestSpecificErrorKeys(t *testing.T) { } val := schema.NewStructValidator() - ValidateKeys(val, configKeys) + ValidateKeys(configKeys, "AUTHELIA_", val) errs := val.Errors() @@ -95,7 +111,7 @@ func TestReplacedErrors(t *testing.T) { } val := schema.NewStructValidator() - ValidateKeys(val, configKeys) + ValidateKeys(configKeys, "AUTHELIA_", val) warns := val.Warnings() errs := val.Errors() @@ -109,18 +125,3 @@ func TestReplacedErrors(t *testing.T) { assert.EqualError(t, errs[3], fmt.Sprintf(errFmtReplacedConfigurationKey, "logs_file_path", "log.file_path")) assert.EqualError(t, errs[4], fmt.Sprintf(errFmtReplacedConfigurationKey, "logs_level", "log.level")) } - -func TestSecretKeysDontRaiseErrors(t *testing.T) { - configKeys := []string{} - - for _, key := range SecretNames { - configKeys = append(configKeys, SecretNameToEnvName(key)) - configKeys = append(configKeys, key) - } - - val := schema.NewStructValidator() - ValidateKeys(val, configKeys) - - assert.Len(t, val.Warnings(), 0) - assert.Len(t, val.Errors(), 0) -} diff --git a/internal/configuration/validator/logging.go b/internal/configuration/validator/logging.go index e1a2704d..71014931 100644 --- a/internal/configuration/validator/logging.go +++ b/internal/configuration/validator/logging.go @@ -12,16 +12,16 @@ import ( func ValidateLogging(configuration *schema.Configuration, validator *schema.StructValidator) { applyDeprecatedLoggingConfiguration(configuration, validator) // TODO: DEPRECATED LINE. Remove in 4.33.0. - if configuration.Logging.Level == "" { - configuration.Logging.Level = schema.DefaultLoggingConfiguration.Level + if configuration.Log.Level == "" { + configuration.Log.Level = schema.DefaultLoggingConfiguration.Level } - if configuration.Logging.Format == "" { - configuration.Logging.Format = schema.DefaultLoggingConfiguration.Format + if configuration.Log.Format == "" { + configuration.Log.Format = schema.DefaultLoggingConfiguration.Format } - if !utils.IsStringInSlice(configuration.Logging.Level, validLoggingLevels) { - validator.Push(fmt.Errorf(errFmtLoggingLevelInvalid, configuration.Logging.Level, strings.Join(validLoggingLevels, ", "))) + if !utils.IsStringInSlice(configuration.Log.Level, validLoggingLevels) { + validator.Push(fmt.Errorf(errFmtLoggingLevelInvalid, configuration.Log.Level, strings.Join(validLoggingLevels, ", "))) } } @@ -30,24 +30,24 @@ func applyDeprecatedLoggingConfiguration(configuration *schema.Configuration, va if configuration.LogLevel != "" { validator.PushWarning(fmt.Errorf(errFmtDeprecatedConfigurationKey, "log_level", "4.33.0", "log.level")) - if configuration.Logging.Level == "" { - configuration.Logging.Level = configuration.LogLevel + if configuration.Log.Level == "" { + configuration.Log.Level = configuration.LogLevel } } if configuration.LogFormat != "" { validator.PushWarning(fmt.Errorf(errFmtDeprecatedConfigurationKey, "log_format", "4.33.0", "log.format")) - if configuration.Logging.Format == "" { - configuration.Logging.Format = configuration.LogFormat + if configuration.Log.Format == "" { + configuration.Log.Format = configuration.LogFormat } } if configuration.LogFilePath != "" { validator.PushWarning(fmt.Errorf(errFmtDeprecatedConfigurationKey, "log_file_path", "4.33.0", "log.file_path")) - if configuration.Logging.FilePath == "" { - configuration.Logging.FilePath = configuration.LogFilePath + if configuration.Log.FilePath == "" { + configuration.Log.FilePath = configuration.LogFilePath } } } diff --git a/internal/configuration/validator/logging_test.go b/internal/configuration/validator/logging_test.go index 49ba9c71..354c5db6 100644 --- a/internal/configuration/validator/logging_test.go +++ b/internal/configuration/validator/logging_test.go @@ -20,20 +20,20 @@ func TestShouldSetDefaultLoggingValues(t *testing.T) { assert.Len(t, validator.Warnings(), 0) assert.Len(t, validator.Errors(), 0) - require.NotNil(t, config.Logging.KeepStdout) + require.NotNil(t, config.Log.KeepStdout) - assert.Equal(t, "", config.LogLevel) - assert.Equal(t, "", config.LogFormat) - assert.Equal(t, "", config.LogFilePath) + assert.Equal(t, "", config.LogLevel) // TODO: DEPRECATED TEST. Remove in 4.33.0. + assert.Equal(t, "", config.LogFormat) // TODO: DEPRECATED TEST. Remove in 4.33.0. + assert.Equal(t, "", config.LogFilePath) // TODO: DEPRECATED TEST. Remove in 4.33.0. - assert.Equal(t, "info", config.Logging.Level) - assert.Equal(t, "text", config.Logging.Format) - assert.Equal(t, "", config.Logging.FilePath) + assert.Equal(t, "info", config.Log.Level) + assert.Equal(t, "text", config.Log.Format) + assert.Equal(t, "", config.Log.FilePath) } func TestShouldRaiseErrorOnInvalidLoggingLevel(t *testing.T) { config := &schema.Configuration{ - Logging: schema.LogConfiguration{ + Log: schema.LogConfiguration{ Level: "TRACE", }, } @@ -63,15 +63,15 @@ func TestShouldMigrateDeprecatedLoggingConfig(t *testing.T) { assert.Len(t, validator.Errors(), 0) require.Len(t, validator.Warnings(), 3) - require.NotNil(t, config.Logging.KeepStdout) + require.NotNil(t, config.Log.KeepStdout) assert.Equal(t, "trace", config.LogLevel) assert.Equal(t, "json", config.LogFormat) assert.Equal(t, "/a/b/c", config.LogFilePath) - assert.Equal(t, "trace", config.Logging.Level) - assert.Equal(t, "json", config.Logging.Format) - assert.Equal(t, "/a/b/c", config.Logging.FilePath) + assert.Equal(t, "trace", config.Log.Level) + assert.Equal(t, "json", config.Log.Format) + assert.Equal(t, "/a/b/c", config.Log.FilePath) assert.EqualError(t, validator.Warnings()[0], fmt.Sprintf(errFmtDeprecatedConfigurationKey, "log_level", "4.33.0", "log.level")) assert.EqualError(t, validator.Warnings()[1], fmt.Sprintf(errFmtDeprecatedConfigurationKey, "log_format", "4.33.0", "log.format")) @@ -80,7 +80,7 @@ func TestShouldMigrateDeprecatedLoggingConfig(t *testing.T) { func TestShouldRaiseErrorsAndNotOverwriteConfigurationWhenUsingDeprecatedLoggingConfig(t *testing.T) { config := &schema.Configuration{ - Logging: schema.LogConfiguration{ + Log: schema.LogConfiguration{ Level: "info", Format: "text", FilePath: "/x/y/z", @@ -95,12 +95,12 @@ func TestShouldRaiseErrorsAndNotOverwriteConfigurationWhenUsingDeprecatedLogging ValidateLogging(config, validator) - require.NotNil(t, config.Logging.KeepStdout) + require.NotNil(t, config.Log.KeepStdout) - assert.Equal(t, "info", config.Logging.Level) - assert.Equal(t, "text", config.Logging.Format) - assert.True(t, config.Logging.KeepStdout) - assert.Equal(t, "/x/y/z", config.Logging.FilePath) + assert.Equal(t, "info", config.Log.Level) + assert.Equal(t, "text", config.Log.Format) + assert.True(t, config.Log.KeepStdout) + assert.Equal(t, "/x/y/z", config.Log.FilePath) assert.Len(t, validator.Errors(), 0) require.Len(t, validator.Warnings(), 3) diff --git a/internal/configuration/validator/secrets.go b/internal/configuration/validator/secrets.go deleted file mode 100644 index 285c413a..00000000 --- a/internal/configuration/validator/secrets.go +++ /dev/null @@ -1,89 +0,0 @@ -package validator - -import ( - "fmt" - "io/ioutil" - "strings" - - "github.com/spf13/viper" - - "github.com/authelia/authelia/internal/configuration/schema" -) - -// SecretNameToEnvName converts a secret name into the env name. -func SecretNameToEnvName(secretName string) (envName string) { - return "authelia." + secretName + ".file" -} - -func isSecretKey(value string) (isSecretKey bool) { - for _, secretKey := range SecretNames { - if value == secretKey || value == SecretNameToEnvName(secretKey) { - return true - } - } - - return false -} - -// ValidateSecrets checks that secrets are either specified by config file/env or by file references. -func ValidateSecrets(configuration *schema.Configuration, validator *schema.StructValidator, viper *viper.Viper) { - configuration.JWTSecret = getSecretValue(SecretNames["JWTSecret"], validator, viper) - configuration.Session.Secret = getSecretValue(SecretNames["SessionSecret"], validator, viper) - - if configuration.DuoAPI != nil { - configuration.DuoAPI.SecretKey = getSecretValue(SecretNames["DUOSecretKey"], validator, viper) - } - - if configuration.Session.Redis != nil { - configuration.Session.Redis.Password = getSecretValue(SecretNames["RedisPassword"], validator, viper) - - if configuration.Session.Redis.HighAvailability != nil { - configuration.Session.Redis.HighAvailability.SentinelPassword = - getSecretValue(SecretNames["RedisSentinelPassword"], validator, viper) - } - } - - if configuration.AuthenticationBackend.LDAP != nil { - configuration.AuthenticationBackend.LDAP.Password = getSecretValue(SecretNames["LDAPPassword"], validator, viper) - } - - if configuration.Notifier != nil && configuration.Notifier.SMTP != nil { - configuration.Notifier.SMTP.Password = getSecretValue(SecretNames["SMTPPassword"], validator, viper) - } - - if configuration.Storage.MySQL != nil { - configuration.Storage.MySQL.Password = getSecretValue(SecretNames["MySQLPassword"], validator, viper) - } - - if configuration.Storage.PostgreSQL != nil { - configuration.Storage.PostgreSQL.Password = getSecretValue(SecretNames["PostgreSQLPassword"], validator, viper) - } - - if configuration.IdentityProviders.OIDC != nil { - configuration.IdentityProviders.OIDC.HMACSecret = getSecretValue(SecretNames["OpenIDConnectHMACSecret"], validator, viper) - configuration.IdentityProviders.OIDC.IssuerPrivateKey = getSecretValue(SecretNames["OpenIDConnectIssuerPrivateKey"], validator, viper) - } -} - -func getSecretValue(name string, validator *schema.StructValidator, viper *viper.Viper) string { - configValue := viper.GetString(name) - fileEnvValue := viper.GetString(SecretNameToEnvName(name)) - - // Error Checking. - if fileEnvValue != "" && configValue != "" { - validator.Push(fmt.Errorf("error loading secret (%s): it's already defined in the config file", name)) - } - - // Derive Secret. - if fileEnvValue != "" { - content, err := ioutil.ReadFile(fileEnvValue) - if err != nil { - validator.Push(fmt.Errorf("error loading secret file (%s): %s", name, err)) - } else { - // TODO: Test this functionality. - return strings.TrimRight(string(content), "\n") - } - } - - return configValue -} diff --git a/internal/configuration/validator/secrets_test.go b/internal/configuration/validator/secrets_test.go deleted file mode 100644 index 6d180dfd..00000000 --- a/internal/configuration/validator/secrets_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package validator - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestShouldValidateCorrectSecretKeys(t *testing.T) { - assert.True(t, isSecretKey("jwt_secret")) - assert.True(t, isSecretKey("authelia.jwt_secret.file")) - assert.False(t, isSecretKey("totp.issuer")) -} - -func TestShouldCreateCorrectSecretEnvNames(t *testing.T) { - assert.Equal(t, "authelia.jwt_secret.file", SecretNameToEnvName("jwt_secret")) - assert.Equal(t, "authelia.not_a_real_secret.file", SecretNameToEnvName("not_a_real_secret")) -} diff --git a/internal/handlers/handler_verify.go b/internal/handlers/handler_verify.go index 07fea270..38c5f976 100644 --- a/internal/handlers/handler_verify.go +++ b/internal/handlers/handler_verify.go @@ -361,7 +361,7 @@ func verifySessionHasUpToDateProfile(ctx *middlewares.AutheliaCtx, targetURL *ur } } else { ctx.Logger.Debugf("Updated profile detected for %s.", userSession.Username) - if ctx.Configuration.Logging.Level == "trace" { + if ctx.Configuration.Log.Level == "trace" { generateVerifySessionHasUpToDateProfileTraceLogs(ctx, userSession, details) } userSession.Emails = details.Emails diff --git a/internal/logging/logger.go b/internal/logging/logger.go index b4114ef7..5193863c 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -6,45 +6,44 @@ import ( logrus_stack "github.com/Gurpartap/logrus-stack" "github.com/sirupsen/logrus" + + "github.com/authelia/authelia/internal/configuration/schema" ) -// Logger return the standard logrus logger. +// Logger returns the standard logrus logger. func Logger() *logrus.Logger { return logrus.StandardLogger() } -// SetLevel set the level of the logger. -func SetLevel(level logrus.Level) { - logrus.SetLevel(level) -} +// InitializeLogger configures the default loggers stack levels, formatting, and the output destinations. +func InitializeLogger(config schema.LogConfiguration, log bool) error { + setLevelStr(config.Level, log) -// InitializeLogger initialize logger. -func InitializeLogger(format, filename string, stdout bool) error { callerLevels := []logrus.Level{} stackLevels := []logrus.Level{logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel} logrus.AddHook(logrus_stack.NewHook(callerLevels, stackLevels)) - if format == logFormatJSON { + if config.Format == logFormatJSON { logrus.SetFormatter(&logrus.JSONFormatter{}) } else { logrus.SetFormatter(&logrus.TextFormatter{}) } - if filename != "" { - f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if config.FilePath != "" { + f, err := os.OpenFile(config.FilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return err } - if format != logFormatJSON { + if config.Format != logFormatJSON { logrus.SetFormatter(&logrus.TextFormatter{ DisableColors: true, FullTimestamp: true, }) } - if stdout { + if config.KeepStdout { logLocations := io.MultiWriter(os.Stdout, f) logrus.SetOutput(logLocations) } else { @@ -54,3 +53,26 @@ func InitializeLogger(format, filename string, stdout bool) error { return nil } + +func setLevelStr(level string, log bool) { + switch level { + case "error": + logrus.SetLevel(logrus.ErrorLevel) + case "warn": + logrus.SetLevel(logrus.WarnLevel) + case "info": + logrus.SetLevel(logrus.InfoLevel) + case "debug": + logrus.SetLevel(logrus.DebugLevel) + case "trace": + logrus.SetLevel(logrus.TraceLevel) + default: + level = "info (default)" + + logrus.SetLevel(logrus.InfoLevel) + } + + if log { + logrus.Infof("Log severity set to %s", level) + } +} diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index a68e0824..fbcdfe15 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/authelia/authelia/internal/configuration/schema" ) func TestShouldWriteLogsToFile(t *testing.T) { @@ -20,7 +22,7 @@ func TestShouldWriteLogsToFile(t *testing.T) { defer os.RemoveAll(dir) path := fmt.Sprintf("%s/authelia.log", dir) - err = InitializeLogger("text", path, false) + err = InitializeLogger(schema.LogConfiguration{Format: "text", FilePath: path, KeepStdout: false}, false) require.NoError(t, err) Logger().Info("This is a test") @@ -43,7 +45,7 @@ func TestShouldWriteLogsToFileAndStdout(t *testing.T) { defer os.RemoveAll(dir) path := fmt.Sprintf("%s/authelia.log", dir) - err = InitializeLogger("text", path, true) + err = InitializeLogger(schema.LogConfiguration{Format: "text", FilePath: path, KeepStdout: true}, false) require.NoError(t, err) Logger().Info("This is a test") @@ -66,7 +68,7 @@ func TestShouldFormatLogsAsJSON(t *testing.T) { defer os.RemoveAll(dir) path := fmt.Sprintf("%s/authelia.log", dir) - err = InitializeLogger("json", path, false) + err = InitializeLogger(schema.LogConfiguration{Format: "json", FilePath: path, KeepStdout: false}, false) require.NoError(t, err) Logger().Info("This is a test") diff --git a/internal/server/server.go b/internal/server/server.go index 7cad3664..ef5c2f6c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -153,8 +153,8 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr return handler } -// StartServer start Authelia server with the given configuration and providers. -func StartServer(configuration schema.Configuration, providers middlewares.Providers) { +// Start Authelia's internal webserver with the given configuration and providers. +func Start(configuration schema.Configuration, providers middlewares.Providers) { logger := logging.Logger() handler := registerRoutes(configuration, providers) @@ -192,10 +192,10 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi } if configuration.Server.TLS.Certificate != "" && configuration.Server.TLS.Key != "" { - logger.Infof("Authelia is listening for TLS connections on %s%s", addrPattern, configuration.Server.Path) + logger.Infof("Listening for TLS connections on %s%s", addrPattern, configuration.Server.Path) logger.Fatal(server.ServeTLS(listener, configuration.Server.TLS.Certificate, configuration.Server.TLS.Key)) } else { - logger.Infof("Authelia is listening for non-TLS connections on %s%s", addrPattern, configuration.Server.Path) + logger.Infof("Listening for non-TLS connections on %s%s", addrPattern, configuration.Server.Path) logger.Fatal(server.Serve(listener)) } } diff --git a/internal/suites/environment.go b/internal/suites/environment.go index 8e685483..cde018e9 100644 --- a/internal/suites/environment.go +++ b/internal/suites/environment.go @@ -46,7 +46,7 @@ func waitUntilAutheliaBackendIsReady(dockerEnvironment *DockerEnvironment) error 90*time.Second, dockerEnvironment, "authelia-backend", - []string{"Authelia is listening for"}) + []string{"Listening for"}) } func waitUntilAutheliaFrontendIsReady(dockerEnvironment *DockerEnvironment) error { diff --git a/internal/suites/suite_cli_test.go b/internal/suites/suite_cli_test.go index eef2d714..b0999b72 100644 --- a/internal/suites/suite_cli_test.go +++ b/internal/suites/suite_cli_test.go @@ -39,7 +39,7 @@ func (s *CLISuite) SetupTest() { } func (s *CLISuite) TestShouldPrintBuildInformation() { - output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "build"}) + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "build-info"}) s.Assert().Nil(err) s.Assert().Contains(output, "Last Tag: ") s.Assert().Contains(output, "State: ") @@ -86,76 +86,76 @@ func (s *CLISuite) TestShouldHashPasswordSHA512() { func (s *CLISuite) TestShouldGenerateCertificateRSA() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldGenerateCertificateRSAWithIPAddress() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "127.0.0.1", "--dir", "/tmp/"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldGenerateCertificateRSAWithStartDate() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--start-date", "'Jan 1 15:04:05 2011'"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldFailGenerateCertificateRSAWithStartDate() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--start-date", "Jan"}) s.Assert().NotNil(err) - s.Assert().Contains(output, "Failed to parse creation date: parsing time \"Jan\" as \"Jan 2 15:04:05 2006\": cannot parse \"\" as \"2\"") + s.Assert().Contains(output, "Failed to parse start date: parsing time \"Jan\" as \"Jan 2 15:04:05 2006\": cannot parse \"\" as \"2\"") } func (s *CLISuite) TestShouldGenerateCertificateCA() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ca"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldGenerateCertificateEd25519() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ed25519"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldFailGenerateCertificateECDSA() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ecdsa-curve", "invalid"}) s.Assert().NotNil(err) - s.Assert().Contains(output, "Unrecognized elliptic curve: \"invalid\"") + s.Assert().Contains(output, "Failed to generate private key: unrecognized elliptic curve: \"invalid\"") } func (s *CLISuite) TestShouldGenerateCertificateECDSAP224() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ecdsa-curve", "P224"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldGenerateCertificateECDSAP256() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ecdsa-curve", "P256"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldGenerateCertificateECDSAP384() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ecdsa-curve", "P384"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func (s *CLISuite) TestShouldGenerateCertificateECDSAP521() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "certificates", "generate", "--host", "*.example.com", "--dir", "/tmp/", "--ecdsa-curve", "P521"}) s.Assert().Nil(err) - s.Assert().Contains(output, "wrote /tmp/cert.pem") - s.Assert().Contains(output, "wrote /tmp/key.pem") + s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") + s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } func TestCLISuite(t *testing.T) { diff --git a/internal/utils/certificates.go b/internal/utils/certificates.go index c8190f5a..5710c036 100644 --- a/internal/utils/certificates.go +++ b/internal/utils/certificates.go @@ -5,7 +5,7 @@ import ( "crypto/x509" "fmt" "io/ioutil" - "path" + "path/filepath" "strings" "github.com/authelia/authelia/internal/configuration/schema" @@ -28,10 +28,10 @@ func NewTLSConfig(config *schema.TLSConfig, defaultMinVersion uint16, certPool * } // NewX509CertPool generates a x509.CertPool from the system PKI and the directory specified. -func NewX509CertPool(directory string) (certPool *x509.CertPool, errors []error, nonFatalErrors []error) { +func NewX509CertPool(directory string) (certPool *x509.CertPool, warnings []error, errors []error) { certPool, err := x509.SystemCertPool() if err != nil { - nonFatalErrors = append(nonFatalErrors, fmt.Errorf("could not load system certificate pool which may result in untrusted certificate issues: %v", err)) + warnings = append(warnings, fmt.Errorf("could not load system certificate pool which may result in untrusted certificate issues: %v", err)) certPool = x509.NewCertPool() } @@ -48,7 +48,7 @@ func NewX509CertPool(directory string) (certPool *x509.CertPool, errors []error, nameLower := strings.ToLower(certFileInfo.Name()) if !certFileInfo.IsDir() && (strings.HasSuffix(nameLower, ".cer") || strings.HasSuffix(nameLower, ".crt") || strings.HasSuffix(nameLower, ".pem")) { - certPath := path.Join(directory, certFileInfo.Name()) + certPath := filepath.Join(directory, certFileInfo.Name()) logger.Tracef("Found possible cert %s, attempting to add it to the pool", certPath) @@ -65,7 +65,7 @@ func NewX509CertPool(directory string) (certPool *x509.CertPool, errors []error, logger.Tracef("Finished scan of directory %s for certificates", directory) - return certPool, errors, nonFatalErrors + return certPool, warnings, errors } // TLSStringToTLSConfigVersion returns a go crypto/tls version for a tls.Config based on string input. diff --git a/internal/utils/certificates_test.go b/internal/utils/certificates_test.go index 716562c9..63319188 100644 --- a/internal/utils/certificates_test.go +++ b/internal/utils/certificates_test.go @@ -77,50 +77,50 @@ func TestShouldReturnZeroAndErrorOnInvalidTLSVersions(t *testing.T) { } func TestShouldReturnErrWhenX509DirectoryNotExist(t *testing.T) { - pool, errs, nonFatalErrs := NewX509CertPool("/tmp/asdfzyxabc123/not/a/real/dir") + pool, warnings, errors := NewX509CertPool("/tmp/asdfzyxabc123/not/a/real/dir") assert.NotNil(t, pool) if runtime.GOOS == windows { - require.Len(t, nonFatalErrs, 1) - assert.EqualError(t, nonFatalErrs[0], "could not load system certificate pool which may result in untrusted certificate issues: crypto/x509: system root pool is not available on Windows") + require.Len(t, warnings, 1) + assert.EqualError(t, warnings[0], "could not load system certificate pool which may result in untrusted certificate issues: crypto/x509: system root pool is not available on Windows") } else { - assert.Len(t, nonFatalErrs, 0) + assert.Len(t, warnings, 0) } - require.Len(t, errs, 1) + require.Len(t, errors, 1) if runtime.GOOS == windows { - assert.EqualError(t, errs[0], "could not read certificates from directory open /tmp/asdfzyxabc123/not/a/real/dir: The system cannot find the path specified.") + assert.EqualError(t, errors[0], "could not read certificates from directory open /tmp/asdfzyxabc123/not/a/real/dir: The system cannot find the path specified.") } else { - assert.EqualError(t, errs[0], "could not read certificates from directory open /tmp/asdfzyxabc123/not/a/real/dir: no such file or directory") + assert.EqualError(t, errors[0], "could not read certificates from directory open /tmp/asdfzyxabc123/not/a/real/dir: no such file or directory") } } func TestShouldNotReturnErrWhenX509DirectoryExist(t *testing.T) { - pool, errs, nonFatalErrs := NewX509CertPool("/tmp") + pool, warnings, errors := NewX509CertPool("/tmp") assert.NotNil(t, pool) if runtime.GOOS == windows { - require.Len(t, nonFatalErrs, 1) - assert.EqualError(t, nonFatalErrs[0], "could not load system certificate pool which may result in untrusted certificate issues: crypto/x509: system root pool is not available on Windows") + require.Len(t, warnings, 1) + assert.EqualError(t, warnings[0], "could not load system certificate pool which may result in untrusted certificate issues: crypto/x509: system root pool is not available on Windows") } else { - assert.Len(t, nonFatalErrs, 0) + assert.Len(t, warnings, 0) } - assert.Len(t, errs, 0) + assert.Len(t, errors, 0) } func TestShouldReadCertsFromDirectoryButNotKeys(t *testing.T) { - pool, errs, nonFatalErrs := NewX509CertPool("../suites/common/ssl/") + pool, warnings, errors := NewX509CertPool("../suites/common/ssl/") assert.NotNil(t, pool) - require.Len(t, errs, 1) + require.Len(t, errors, 1) if runtime.GOOS == "windows" { - require.Len(t, nonFatalErrs, 1) - assert.EqualError(t, nonFatalErrs[0], "could not load system certificate pool which may result in untrusted certificate issues: crypto/x509: system root pool is not available on Windows") + require.Len(t, warnings, 1) + assert.EqualError(t, warnings[0], "could not load system certificate pool which may result in untrusted certificate issues: crypto/x509: system root pool is not available on Windows") } else { - assert.Len(t, nonFatalErrs, 0) + assert.Len(t, warnings, 0) } - assert.EqualError(t, errs[0], "could not import certificate key.pem") + assert.EqualError(t, errors[0], "could not import certificate key.pem") } diff --git a/internal/utils/const.go b/internal/utils/const.go index 13cf000a..56ca197e 100644 --- a/internal/utils/const.go +++ b/internal/utils/const.go @@ -44,6 +44,8 @@ const ( clean = "clean" tagged = "tagged" unknown = "unknown" + + errFmtLinuxNotFound = "open %s: no such file or directory" ) var ( diff --git a/internal/utils/errs.go b/internal/utils/errs.go new file mode 100644 index 00000000..7dc0f828 --- /dev/null +++ b/internal/utils/errs.go @@ -0,0 +1,42 @@ +package utils + +import "runtime" + +// ErrSliceSortAlphabetical is a helper type that can be used with sort.Sort to sort a slice of errors in alphabetical +// order. Usage is simple just do sort.Sort(ErrSliceSortAlphabetical([]error{})). +type ErrSliceSortAlphabetical []error + +func (s ErrSliceSortAlphabetical) Len() int { return len(s) } + +func (s ErrSliceSortAlphabetical) Less(i, j int) bool { return s[i].Error() < s[j].Error() } + +func (s ErrSliceSortAlphabetical) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// GetExpectedErrTxt returns error text for expected errs. +func GetExpectedErrTxt(err string) string { + switch err { + case "pathnotfound": + switch runtime.GOOS { + case windows: + return "open %s: The system cannot find the path specified." + default: + return errFmtLinuxNotFound + } + case "filenotfound": + switch runtime.GOOS { + case windows: + return "open %s: The system cannot find the file specified." + default: + return errFmtLinuxNotFound + } + case "yamlisdir": + switch runtime.GOOS { + case windows: + return "read %s: The handle is invalid." + default: + return "read %s: is a directory" + } + } + + return "" +} diff --git a/internal/utils/hashing.go b/internal/utils/hashing.go index 16a670bc..86e0129f 100644 --- a/internal/utils/hashing.go +++ b/internal/utils/hashing.go @@ -2,12 +2,34 @@ package utils import ( "crypto/sha256" - "fmt" + "encoding/hex" + "io" + "os" ) // HashSHA256FromString takes an input string and calculates the SHA256 checksum returning it as a base16 hash string. func HashSHA256FromString(input string) (output string) { - sum := sha256.Sum256([]byte(input)) + hash := sha256.New() - return fmt.Sprintf("%x", sum) + hash.Write([]byte(input)) + + return hex.EncodeToString(hash.Sum(nil)) +} + +// HashSHA256FromPath takes a path string and calculates the SHA256 checksum of the file at the path returning it as a base16 hash string. +func HashSHA256FromPath(path string) (output string, err error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + + defer file.Close() + + hash := sha256.New() + + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/internal/utils/hashing_test.go b/internal/utils/hashing_test.go new file mode 100644 index 00000000..77805051 --- /dev/null +++ b/internal/utils/hashing_test.go @@ -0,0 +1,67 @@ +package utils + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldHashString(t *testing.T) { + input := "input" + anotherInput := "another" + + sum := HashSHA256FromString(input) + + assert.Equal(t, "c96c6d5be8d08a12e7b5cdc1b207fa6b2430974c86803d8891675e76fd992c20", sum) + + anotherSum := HashSHA256FromString(anotherInput) + + assert.Equal(t, "ae448ac86c4e8e4dec645729708ef41873ae79c6dff84eff73360989487f08e5", anotherSum) + assert.NotEqual(t, sum, anotherSum) + + randomInput := RandomString(40, AlphaNumericCharacters) + randomSum := HashSHA256FromString(randomInput) + + assert.NotEqual(t, randomSum, sum) + assert.NotEqual(t, randomSum, anotherSum) +} + +func TestShouldHashPath(t *testing.T) { + dir, err := ioutil.TempDir("", "authelia-hashing") + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(dir, "myfile"), []byte("output\n"), 0600) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(dir, "anotherfile"), []byte("another\n"), 0600) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(dir, "randomfile"), []byte(RandomString(40, AlphaNumericCharacters)+"\n"), 0600) + assert.NoError(t, err) + + sum, err := HashSHA256FromPath(filepath.Join(dir, "myfile")) + + assert.NoError(t, err) + assert.Equal(t, "9aff6ba4b042b9d09991a9fbf8c80ddbd2a9c433638339cd831bed955e39f106", sum) + + anotherSum, err := HashSHA256FromPath(filepath.Join(dir, "anotherfile")) + + assert.NoError(t, err) + assert.Equal(t, "33a7b215065f2ee8635efb72620bc269a1efb889ba3026560334da7366742374", anotherSum) + + randomSum, err := HashSHA256FromPath(filepath.Join(dir, "randomfile")) + + assert.NoError(t, err) + assert.NotEqual(t, randomSum, sum) + assert.NotEqual(t, randomSum, anotherSum) + + sum, err = HashSHA256FromPath(filepath.Join(dir, "notafile")) + assert.Equal(t, "", sum) + + errTxt := GetExpectedErrTxt("filenotfound") + assert.EqualError(t, err, fmt.Sprintf(errTxt, filepath.Join(dir, "notafile"))) +} diff --git a/internal/utils/strings.go b/internal/utils/strings.go index ae2835aa..ed930705 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -46,6 +46,17 @@ func IsStringInSlice(needle string, haystack []string) (inSlice bool) { return false } +// IsStringInSliceSuffix checks if the needle string has one of the suffixes in the haystack. +func IsStringInSliceSuffix(needle string, haystack []string) (hasSuffix bool) { + for _, straw := range haystack { + if strings.HasSuffix(needle, straw) { + return true + } + } + + return false +} + // IsStringInSliceFold checks if a single string is in a slice of strings but uses strings.EqualFold to compare them. func IsStringInSliceFold(needle string, haystack []string) (inSlice bool) { for _, b := range haystack { diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go index a774ed52..b876b72d 100644 --- a/internal/utils/strings_test.go +++ b/internal/utils/strings_test.go @@ -131,3 +131,14 @@ func TestShouldNotFindStringInSliceFold(t *testing.T) { assert.False(t, IsStringInSliceFold(a, slice)) assert.False(t, IsStringInSliceFold(b, slice)) } + +func TestIsStringInSliceSuffix(t *testing.T) { + suffixes := []string{"apple", "banana"} + + assert.True(t, IsStringInSliceSuffix("apple.banana", suffixes)) + assert.True(t, IsStringInSliceSuffix("a.banana", suffixes)) + assert.True(t, IsStringInSliceSuffix("a_banana", suffixes)) + assert.True(t, IsStringInSliceSuffix("an.apple", suffixes)) + assert.False(t, IsStringInSliceSuffix("an.orange", suffixes)) + assert.False(t, IsStringInSliceSuffix("an.apple.orange", suffixes)) +}