From 347bd1be779266742aac4a1576fe58ed20b96d3b Mon Sep 17 00:00:00 2001 From: James Elliott Date: Thu, 25 Nov 2021 12:56:58 +1100 Subject: [PATCH] feat(storage): encrypted secret values (#2588) This adds an AES-GCM 256bit encryption layer for storage for sensitive items. This is only TOTP secrets for the time being but this may be expanded later. This will require a configuration change as per https://www.authelia.com/docs/configuration/migration.html#4330. Closes #682 --- config.template.yml | 4 + docs/configuration/secrets.md | 1 + docs/configuration/storage/index.md | 38 ++- docs/configuration/storage/mariadb.md | 4 + docs/configuration/storage/mysql.md | 4 + docs/configuration/storage/postgres.md | 4 + docs/configuration/storage/sqlite.md | 4 + internal/commands/const.go | 13 + internal/commands/helpers.go | 86 +++++- internal/commands/helpers_test.go | 15 + internal/commands/root.go | 76 +---- internal/commands/storage.go | 65 ++++ internal/commands/storage_run.go | 236 +++++++++++++-- internal/configuration/config.template.yml | 4 + internal/configuration/provider_test.go | 5 + internal/configuration/schema/storage.go | 2 + .../validator/configuration_test.go | 1 + internal/configuration/validator/const.go | 3 + internal/configuration/validator/storage.go | 6 + .../configuration/validator/storage_test.go | 21 ++ internal/handlers/handler_register_totp.go | 2 +- internal/handlers/handler_sign_totp_test.go | 12 +- internal/handlers/totp.go | 2 +- internal/models/model_totp_configuration.go | 2 +- internal/storage/const.go | 5 + internal/storage/errors.go | 14 + internal/storage/migrations.go | 2 +- .../V0001.Initial_Schema.all.down.sql | 1 + .../V0001.Initial_Schema.mysql.up.sql | 10 +- .../V0001.Initial_Schema.postgres.up.sql | 10 +- .../V0001.Initial_Schema.sqlite.up.sql | 10 +- internal/storage/provider.go | 7 + internal/storage/provider_mock.go | 81 ++++- internal/storage/sql_provider.go | 277 ++++++++++++------ .../storage/sql_provider_backend_mysql.go | 4 +- .../storage/sql_provider_backend_postgres.go | 9 +- .../storage/sql_provider_backend_sqlite.go | 4 +- internal/storage/sql_provider_encryption.go | 185 ++++++++++++ internal/storage/sql_provider_queries.go | 35 +++ internal/storage/sql_provider_schema.go | 15 +- internal/storage/sql_provider_schema_pre1.go | 27 +- .../suites/ActiveDirectory/configuration.yml | 1 + internal/suites/BypassAll/configuration.yml | 1 + internal/suites/CLI/configuration.yml | 5 +- internal/suites/CLI/docker-compose.yml | 3 + internal/suites/CLI/storage.yml | 6 + internal/suites/Docker/configuration.yml | 1 + internal/suites/DuoPush/configuration.yml | 1 + internal/suites/HAProxy/configuration.yml | 1 + .../suites/HighAvailability/configuration.yml | 1 + internal/suites/LDAP/configuration.yml | 1 + internal/suites/Mariadb/configuration.yml | 1 + internal/suites/MySQL/configuration.yml | 1 + internal/suites/NetworkACL/configuration.yml | 1 + internal/suites/OIDC/configuration.yml | 1 + internal/suites/OIDCTraefik/configuration.yml | 1 + .../suites/OneFactorOnly/configuration.yml | 1 + internal/suites/PathPrefix/configuration.yml | 1 + internal/suites/Postgres/configuration.yml | 1 + .../suites/ShortTimeouts/configuration.yml | 1 + internal/suites/Standalone/configuration.yml | 1 + internal/suites/Traefik/configuration.yml | 1 + internal/suites/Traefik2/configuration.yml | 1 + .../example/kube/authelia/deployment.yml | 4 + .../suites/example/kube/authelia/secret.yml | 1 + internal/suites/suite_cli.go | 4 + internal/suites/suite_cli_test.go | 241 ++++++++++++++- internal/suites/suite_standalone_test.go | 2 +- 68 files changed, 1338 insertions(+), 253 deletions(-) create mode 100644 internal/commands/helpers_test.go create mode 100644 internal/storage/sql_provider_encryption.go create mode 100644 internal/suites/CLI/storage.yml diff --git a/config.template.yml b/config.template.yml index 2fa3825e9..578701a8a 100644 --- a/config.template.yml +++ b/config.template.yml @@ -507,6 +507,10 @@ regulation: ## ## The available providers are: `local`, `mysql`, `postgres`. You must use one and only one of these providers. storage: + ## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum + ## length of 20. Please see the docs if you configure this with an undesirable key and need to change it. + # encryption_key: you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this + ## ## Local (Storage Provider) ## diff --git a/docs/configuration/secrets.md b/docs/configuration/secrets.md index e630dc6be..5a0b4cb17 100644 --- a/docs/configuration/secrets.md +++ b/docs/configuration/secrets.md @@ -36,6 +36,7 @@ other configuration using the environment but instead of loading a file the valu |session.secret |AUTHELIA_SESSION_SECRET_FILE | |session.redis.password |AUTHELIA_SESSION_REDIS_PASSWORD_FILE | |session.redis.high_availability.sentinel_password|AUTHELIA_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE | +|storage.encryption_key |AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE | |storage.mysql.password |AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE | |storage.postgres.password |AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE | |notifier.smtp.password |AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE | diff --git a/docs/configuration/storage/index.md b/docs/configuration/storage/index.md index 347fa26b5..7a1881289 100644 --- a/docs/configuration/storage/index.md +++ b/docs/configuration/storage/index.md @@ -6,9 +6,43 @@ nav_order: 14 has_children: true --- -# Storage backends - **Authelia** supports multiple storage backends. The backend is used to store user preferences, 2FA device handles and secrets, authentication logs, etc... The available storage backends are listed in the table of contents below. + +## Configuration + +```yaml +storage: + encryption_key: a_very_important_secret + local: {} + mysql: {} + postgres: {} +``` + +## Options + +### encryption_key +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +The encryption key used to encrypt data in the database. It has a minimum length of 20 and must be provided. We encrypt +data by creating a sha256 checksum of the provided value, and use that to encrypt the data with the AES-GCM 256bit +algorithm. + +The encrypted data in the database is as follows: +- TOTP Secret + +### local +See [SQLite](./sqlite.md). + +### mysql +See [MySQL](./mysql.md). + +### postgres +See [PostgreSQL](./postgres.md). \ No newline at end of file diff --git a/docs/configuration/storage/mariadb.md b/docs/configuration/storage/mariadb.md index 43a36f191..be955e849 100644 --- a/docs/configuration/storage/mariadb.md +++ b/docs/configuration/storage/mariadb.md @@ -14,6 +14,7 @@ The MySQL storage provider also serves as a MariaDB provider. ```yaml storage: + encryption_key: a_very_important_secret mysql: host: 127.0.0.1 port: 3306 @@ -24,6 +25,9 @@ storage: ## Options +### encryption_key +See the [encryption_key docs](./index.md#encryption_key). + ### host
type: string diff --git a/docs/configuration/storage/mysql.md b/docs/configuration/storage/mysql.md index acb98f0b6..f7e658d91 100644 --- a/docs/configuration/storage/mysql.md +++ b/docs/configuration/storage/mysql.md @@ -14,6 +14,7 @@ The MySQL storage provider. ```yaml storage: + encryption_key: a_very_important_secret mysql: host: 127.0.0.1 port: 3306 @@ -25,6 +26,9 @@ storage: ## Options +### encryption_key +See the [encryption_key docs](./index.md#encryption_key). + ### host
type: string diff --git a/docs/configuration/storage/postgres.md b/docs/configuration/storage/postgres.md index 525aeeb91..788027cf5 100644 --- a/docs/configuration/storage/postgres.md +++ b/docs/configuration/storage/postgres.md @@ -14,6 +14,7 @@ The PostgreSQL storage provider. ```yaml storage: + encryption_key: a_very_important_secret postgres: host: 127.0.0.1 port: 5432 @@ -25,6 +26,9 @@ storage: ## Options +### encryption_key +See the [encryption_key docs](./index.md#encryption_key). + ### host
type: string diff --git a/docs/configuration/storage/sqlite.md b/docs/configuration/storage/sqlite.md index 7fa8c42b7..21bd34a89 100644 --- a/docs/configuration/storage/sqlite.md +++ b/docs/configuration/storage/sqlite.md @@ -20,12 +20,16 @@ requires you setup an external database. ```yaml storage: + encryption_key: a_very_important_secret local: path: /config/db.sqlite3 ``` ## Options +### encryption_key +See the [encryption_key docs](./index.md#encryption_key). + ### path
type: string diff --git a/internal/commands/const.go b/internal/commands/const.go index c8cd5d1b5..5a5317e69 100644 --- a/internal/commands/const.go +++ b/internal/commands/const.go @@ -1,5 +1,9 @@ package commands +import ( + "errors" +) + 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/ @@ -80,3 +84,12 @@ const ( storageMigrateDirectionUp = "up" storageMigrateDirectionDown = "down" ) + +const ( + storageExportFormatCSV = "csv" + storageExportFormatURI = "uri" +) + +var ( + errNoStorageProvider = errors.New("no storage provider configured") +) diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index e90c78464..70ed2058f 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -1,28 +1,84 @@ package commands import ( - "errors" - + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/notification" + "github.com/authelia/authelia/v4/internal/ntp" + "github.com/authelia/authelia/v4/internal/oidc" + "github.com/authelia/authelia/v4/internal/regulation" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/storage" + "github.com/authelia/authelia/v4/internal/utils" ) -func getStorageProvider() (provider storage.Provider, err error) { +func getStorageProvider() (provider storage.Provider) { switch { case config.Storage.PostgreSQL != nil: - provider = storage.NewPostgreSQLProvider(*config.Storage.PostgreSQL) + return storage.NewPostgreSQLProvider(*config.Storage.PostgreSQL, config.Storage.EncryptionKey) case config.Storage.MySQL != nil: - provider = storage.NewMySQLProvider(*config.Storage.MySQL) + return storage.NewMySQLProvider(*config.Storage.MySQL, config.Storage.EncryptionKey) case config.Storage.Local != nil: - provider = storage.NewSQLiteProvider(config.Storage.Local.Path) + return storage.NewSQLiteProvider(config.Storage.Local.Path, config.Storage.EncryptionKey) default: - return nil, errors.New("no storage provider configured") + return nil } - - if (config.Storage.MySQL != nil && config.Storage.PostgreSQL != nil) || - (config.Storage.MySQL != nil && config.Storage.Local != nil) || - (config.Storage.PostgreSQL != nil && config.Storage.Local != nil) { - return nil, errors.New("multiple storage providers are configured but should only configure one") - } - - return provider, err +} + +func getProviders() (providers middlewares.Providers, warnings []error, errors []error) { + // TODO: Adjust this so the CertPool can be used like a provider. + autheliaCertPool, warnings, errors := utils.NewX509CertPool(config.CertificatesDirectory) + if len(warnings) != 0 || len(errors) != 0 { + return providers, warnings, errors + } + + storageProvider := getStorageProvider() + + var ( + userProvider authentication.UserProvider + err error + ) + + switch { + case config.AuthenticationBackend.File != nil: + userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) + case config.AuthenticationBackend.LDAP != nil: + userProvider = authentication.NewLDAPUserProvider(config.AuthenticationBackend, autheliaCertPool) + } + + 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) + } + + var ntpProvider *ntp.Provider + if config.NTP != nil { + ntpProvider = ntp.NewProvider(config.NTP) + } + + 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, + NTP: ntpProvider, + Notifier: notifier, + SessionProvider: sessionProvider, + }, warnings, errors } diff --git a/internal/commands/helpers_test.go b/internal/commands/helpers_test.go new file mode 100644 index 000000000..797ecfd6c --- /dev/null +++ b/internal/commands/helpers_test.go @@ -0,0 +1,15 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authelia/authelia/v4/internal/configuration/schema" +) + +func TestGetStorageProvider(t *testing.T) { + config = &schema.Configuration{} + + assert.Nil(t, getStorageProvider()) +} diff --git a/internal/commands/root.go b/internal/commands/root.go index b3c96641a..a347428ad 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -8,19 +8,11 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "github.com/authelia/authelia/v4/internal/authentication" - "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/logging" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/models" - "github.com/authelia/authelia/v4/internal/notification" - "github.com/authelia/authelia/v4/internal/ntp" - "github.com/authelia/authelia/v4/internal/oidc" - "github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/server" - "github.com/authelia/authelia/v4/internal/session" - "github.com/authelia/authelia/v4/internal/storage" "github.com/authelia/authelia/v4/internal/utils" ) @@ -67,7 +59,7 @@ func cmdRootRun(_ *cobra.Command, _ []string) { logger.Fatalf("Cannot initialize logger: %v", err) } - providers, warnings, errors := getProviders(config) + providers, warnings, errors := getProviders() if len(warnings) != 0 { for _, err := range warnings { logger.Warn(err) @@ -87,72 +79,6 @@ func cmdRootRun(_ *cobra.Command, _ []string) { server.Start(*config, providers) } -func getProviders(config *schema.Configuration) (providers middlewares.Providers, warnings []error, errors []error) { - // TODO: Adjust this so the CertPool can be used like a provider. - 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) - } - - var ( - userProvider authentication.UserProvider - err error - ) - - switch { - case config.AuthenticationBackend.File != nil: - userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) - case config.AuthenticationBackend.LDAP != nil: - userProvider = authentication.NewLDAPUserProvider(config.AuthenticationBackend, autheliaCertPool) - } - - 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) - } - - var ntpProvider *ntp.Provider - if config.NTP != nil { - ntpProvider = ntp.NewProvider(config.NTP) - } - - 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, - NTP: ntpProvider, - Notifier: notifier, - SessionProvider: sessionProvider, - }, warnings, errors -} - func doStartupChecks(config *schema.Configuration, providers *middlewares.Providers) { logger := logging.Logger() diff --git a/internal/commands/storage.go b/internal/commands/storage.go index f6b62e5a8..8d3cc2e13 100644 --- a/internal/commands/storage.go +++ b/internal/commands/storage.go @@ -15,6 +15,8 @@ func NewStorageCmd() (cmd *cobra.Command) { cmd.PersistentFlags().StringSliceP("config", "c", []string{"config.yml"}, "configuration file to load for the storage migration") + cmd.PersistentFlags().String("encryption-key", "", "the storage encryption key to use") + cmd.PersistentFlags().String("sqlite.path", "", "the SQLite database path") cmd.PersistentFlags().String("mysql.host", "", "the MySQL hostname") @@ -32,11 +34,74 @@ func NewStorageCmd() (cmd *cobra.Command) { cmd.AddCommand( newStorageMigrateCmd(), newStorageSchemaInfoCmd(), + newStorageEncryptionCmd(), + newStorageExportCmd(), ) return cmd } +func newStorageEncryptionCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "encryption", + Short: "Manages encryption", + } + + cmd.AddCommand( + newStorageEncryptionChangeKeyCmd(), + newStorageEncryptionCheckCmd(), + ) + + return cmd +} + +func newStorageEncryptionCheckCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "check", + Short: "Checks the encryption key against the database data", + RunE: storageSchemaEncryptionCheckRunE, + } + + cmd.Flags().Bool("verbose", false, "enables verbose checking of every row of encrypted data") + + return cmd +} + +func newStorageEncryptionChangeKeyCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "change-key", + Short: "Changes the encryption key", + RunE: storageSchemaEncryptionChangeKeyRunE, + } + + cmd.Flags().String("new-encryption-key", "", "the new key to encrypt the data with") + + return cmd +} + +func newStorageExportCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "export", + Short: "Performs exports", + } + + cmd.AddCommand(newStorageExportTOTPConfigurationsCmd()) + + return cmd +} + +func newStorageExportTOTPConfigurationsCmd() (cmd *cobra.Command) { + cmd = &cobra.Command{ + Use: "totp-configurations", + Short: "Performs exports of the totp configurations", + RunE: storageExportTOTPConfigurationsRunE, + } + + cmd.Flags().String("format", storageExportFormatCSV, "changes the format of the export, options are csv and uri") + + return cmd +} + func newStorageSchemaInfoCmd() (cmd *cobra.Command) { cmd = &cobra.Command{ Use: "schema-info", diff --git a/internal/commands/storage_run.go b/internal/commands/storage_run.go index 681683401..0af2e7906 100644 --- a/internal/commands/storage_run.go +++ b/internal/commands/storage_run.go @@ -12,6 +12,7 @@ import ( "github.com/authelia/authelia/v4/internal/configuration" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/validator" + "github.com/authelia/authelia/v4/internal/models" "github.com/authelia/authelia/v4/internal/storage" ) @@ -38,6 +39,7 @@ func storagePersistentPreRunE(cmd *cobra.Command, _ []string) (err error) { } mapping := map[string]string{ + "encryption-key": "storage.encryption_key", "sqlite.path": "storage.local.path", "mysql.host": "storage.mysql.host", "mysql.port": "storage.mysql.port", @@ -100,17 +102,179 @@ func storagePersistentPreRunE(cmd *cobra.Command, _ []string) (err error) { return nil } +func storageSchemaEncryptionCheckRunE(cmd *cobra.Command, args []string) (err error) { + var ( + provider storage.Provider + ctx = context.Background() + ) + + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider + } + + defer func() { + _ = provider.Close() + }() + + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return err + } + + if err = provider.SchemaEncryptionCheckKey(ctx, verbose); err != nil { + switch { + case errors.Is(err, storage.ErrSchemaEncryptionVersionUnsupported): + fmt.Printf("Could not check encryption key for validity. The schema version doesn't support encryption.\n") + case errors.Is(err, storage.ErrSchemaEncryptionInvalidKey): + fmt.Printf("Encryption key validation: failed.\n\nError: %v.\n", err) + default: + fmt.Printf("Could not check encryption key for validity.\n\nError: %v.\n", err) + } + } else { + fmt.Println("Encryption key validation: success.") + } + + return nil +} + +func storageSchemaEncryptionChangeKeyRunE(cmd *cobra.Command, args []string) (err error) { + var ( + provider storage.Provider + ctx = context.Background() + ) + + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider + } + + defer func() { + _ = provider.Close() + }() + + if err = checkStorageSchemaUpToDate(ctx, provider); err != nil { + return err + } + + version, err := provider.SchemaVersion(ctx) + if err != nil { + return err + } + + if version <= 0 { + return errors.New("schema version must be at least version 1 to change the encryption key") + } + + key, err := cmd.Flags().GetString("new-encryption-key") + if err != nil { + return err + } + + if key == "" { + return errors.New("you must set the --new-encryption-key flag") + } + + if len(key) < 20 { + return errors.New("the encryption key must be at least 20 characters") + } + + if err = provider.SchemaEncryptionChangeKey(ctx, key); err != nil { + return err + } + + fmt.Println("Completed the encryption key change. Please adjust your configuration to use the new key.") + + return nil +} + +func storageExportTOTPConfigurationsRunE(cmd *cobra.Command, args []string) (err error) { + var ( + provider storage.Provider + ctx = context.Background() + ) + + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider + } + + defer func() { + _ = provider.Close() + }() + + if err = checkStorageSchemaUpToDate(ctx, provider); err != nil { + return err + } + + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + + switch format { + case storageExportFormatCSV, storageExportFormatURI: + break + default: + return errors.New("format must be csv or uri") + } + + limit := 10 + + var configurations []models.TOTPConfiguration + + for page := 0; true; page++ { + configurations, err = provider.LoadTOTPConfigurations(ctx, limit, page) + if err != nil { + return err + } + + if page == 0 && format == storageExportFormatCSV { + fmt.Printf("issuer,username,algorithm,digits,period,secret\n") + } + + for _, c := range configurations { + switch format { + case storageExportFormatCSV: + fmt.Printf("%s,%s,%s,%d,%d,%s\n", "Authelia", c.Username, c.Algorithm, c.Digits, c.Period, string(c.Secret)) + case storageExportFormatURI: + fmt.Printf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d\n", "Authelia", c.Username, string(c.Secret), "Authelia", c.Algorithm, c.Digits, c.Period) + } + } + + if len(configurations) < limit { + break + } + } + + return nil +} + func storageMigrateHistoryRunE(_ *cobra.Command, _ []string) (err error) { var ( provider storage.Provider ctx = context.Background() ) - provider, err = getStorageProvider() + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider + } + + defer func() { + _ = provider.Close() + }() + + version, err := provider.SchemaVersion(ctx) if err != nil { return err } + if version <= 0 { + fmt.Println("No migration history is available for schemas that not version 1 or above.") + return + } + migrations, err := provider.SchemaMigrationHistory(ctx) if err != nil { return err @@ -138,11 +302,15 @@ func newStorageMigrateListRunE(up bool) func(cmd *cobra.Command, args []string) directionStr string ) - provider, err = getStorageProvider() - if err != nil { - return err + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider } + defer func() { + _ = provider.Close() + }() + if up { migrations, err = provider.SchemaMigrationsUp(ctx, 0) directionStr = "Up" @@ -151,13 +319,7 @@ func newStorageMigrateListRunE(up bool) func(cmd *cobra.Command, args []string) directionStr = "Down" } - if err != nil { - if err.Error() == "cannot migrate to the same version as prior" { - fmt.Printf("No %s migrations found\n", directionStr) - - return nil - } - + if err != nil && !errors.Is(err, storage.ErrNoAvailableMigrations) && !errors.Is(err, storage.ErrMigrateCurrentVersionSameAsTarget) { return err } @@ -182,11 +344,15 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e ctx = context.Background() ) - provider, err = getStorageProvider() - if err != nil { - return err + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider } + defer func() { + _ = provider.Close() + }() + target, err := cmd.Flags().GetInt("target") if err != nil { return err @@ -253,11 +419,15 @@ func storageSchemaInfoRunE(_ *cobra.Command, _ []string) (err error) { tablesStr string ) - provider, err = getStorageProvider() - if err != nil { - return err + provider = getStorageProvider() + if provider == nil { + return errNoStorageProvider } + defer func() { + _ = provider.Close() + }() + version, err := provider.SchemaVersion(ctx) if err != nil && err.Error() != "unknown schema state" { return err @@ -285,7 +455,37 @@ func storageSchemaInfoRunE(_ *cobra.Command, _ []string) (err error) { upgradeStr = "no" } - fmt.Printf("Schema Version: %s\nSchema Upgrade Available: %s\nSchema Tables: %s\n", storage.SchemaVersionToString(version), upgradeStr, tablesStr) + var encryption string + + if err = provider.SchemaEncryptionCheckKey(ctx, false); err != nil { + if errors.Is(err, storage.ErrSchemaEncryptionVersionUnsupported) { + encryption = "unsupported (schema version)" + } else { + encryption = "invalid" + } + } else { + encryption = "valid" + } + + fmt.Printf("Schema Version: %s\nSchema Upgrade Available: %s\nSchema Tables: %s\nSchema Encryption Key: %s\n", storage.SchemaVersionToString(version), upgradeStr, tablesStr, encryption) + + return nil +} + +func checkStorageSchemaUpToDate(ctx context.Context, provider storage.Provider) (err error) { + version, err := provider.SchemaVersion(ctx) + if err != nil { + return err + } + + latest, err := provider.SchemaLatestVersion() + if err != nil { + return err + } + + if version != latest { + return fmt.Errorf("schema is version %d which is outdated please migrate to version %d in order to use this command or use an older binary", version, latest) + } return nil } diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 2fa3825e9..578701a8a 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -507,6 +507,10 @@ regulation: ## ## The available providers are: `local`, `mysql`, `postgres`. You must use one and only one of these providers. storage: + ## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum + ## length of 20. Please see the docs if you configure this with an undesirable key and need to change it. + # encryption_key: you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this + ## ## Local (Storage Provider) ## diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go index d4fc155db..5d800e7ea 100644 --- a/internal/configuration/provider_test.go +++ b/internal/configuration/provider_test.go @@ -138,6 +138,7 @@ func TestShouldValidateAndRaiseErrorsOnNormalConfigurationAndSecret(t *testing.T 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")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_ENCRYPTION_KEY", "a_very_bad_encryption_key")) val := schema.NewStructValidator() _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -152,6 +153,7 @@ func TestShouldValidateAndRaiseErrorsOnNormalConfigurationAndSecret(t *testing.T 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) + assert.Equal(t, "a_very_bad_encryption_key", config.Storage.EncryptionKey) } func TestShouldRaiseIOErrOnUnreadableFile(t *testing.T) { @@ -184,6 +186,7 @@ func TestShouldValidateConfigurationWithEnvSecrets(t *testing.T) { 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")) + assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_ENCRYPTION_KEY_FILE", "./test_resources/example_secret")) val := schema.NewStructValidator() _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -196,6 +199,7 @@ func TestShouldValidateConfigurationWithEnvSecrets(t *testing.T) { 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) + assert.Equal(t, "example_secret value", config.Storage.EncryptionKey) } func TestShouldValidateAndRaiseErrorsOnBadConfiguration(t *testing.T) { @@ -275,6 +279,7 @@ func testReset() { testUnsetEnvName("PORT") testUnsetEnvName("IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY") testUnsetEnvName("IDENTITY_PROVIDERS_OIDC_HMAC_SECRET") + testUnsetEnvName("STORAGE_ENCRYPTION_KEY") } func testUnsetEnvName(name string) { diff --git a/internal/configuration/schema/storage.go b/internal/configuration/schema/storage.go index ecb2a65ca..831872eb6 100644 --- a/internal/configuration/schema/storage.go +++ b/internal/configuration/schema/storage.go @@ -33,6 +33,8 @@ type StorageConfiguration struct { Local *LocalStorageConfiguration `koanf:"local"` MySQL *MySQLStorageConfiguration `koanf:"mysql"` PostgreSQL *PostgreSQLStorageConfiguration `koanf:"postgres"` + + EncryptionKey string `koanf:"encryption_key"` } // DefaultPostgreSQLStorageConfiguration represents the default PostgreSQL configuration. diff --git a/internal/configuration/validator/configuration_test.go b/internal/configuration/validator/configuration_test.go index 39796abfd..6db192c69 100644 --- a/internal/configuration/validator/configuration_test.go +++ b/internal/configuration/validator/configuration_test.go @@ -28,6 +28,7 @@ func newDefaultConfig() schema.Configuration { Name: "authelia_session", Secret: "secret", } + config.Storage.EncryptionKey = testEncryptionKey config.Storage.Local = &schema.LocalStorageConfiguration{ Path: "abc", } diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index b56819e1c..67f6bef9b 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -41,6 +41,7 @@ const ( testModeDisabled = "disable" testTLSCert = "/tmp/cert.pem" testTLSKey = "/tmp/key.pem" + testEncryptionKey = "a_not_so_secure_encryption_key" ) // Notifier Error constants. @@ -206,6 +207,8 @@ var ValidKeys = []string{ "session.redis.timeouts.read", "session.redis.timeouts.write", + "storage.encryption_key", + // Local Storage Keys. "storage.local.path", diff --git a/internal/configuration/validator/storage.go b/internal/configuration/validator/storage.go index 8c303d0a1..b4ab54a2c 100644 --- a/internal/configuration/validator/storage.go +++ b/internal/configuration/validator/storage.go @@ -20,6 +20,12 @@ func ValidateStorage(configuration schema.StorageConfiguration, validator *schem case configuration.Local != nil: validateLocalStorageConfiguration(configuration.Local, validator) } + + if configuration.EncryptionKey == "" { + validator.Push(errors.New("the configuration option storage.encryption_key must be provided")) + } else if len(configuration.EncryptionKey) < 20 { + validator.Push(errors.New("the configuration option storage.encryption_key must be 20 characters or longer")) + } } func validateMySQLConfiguration(configuration *schema.SQLStorageConfiguration, validator *schema.StructValidator) { diff --git a/internal/configuration/validator/storage_test.go b/internal/configuration/validator/storage_test.go index fcec6a836..927d93699 100644 --- a/internal/configuration/validator/storage_test.go +++ b/internal/configuration/validator/storage_test.go @@ -16,6 +16,7 @@ type StorageSuite struct { func (suite *StorageSuite) SetupTest() { suite.validator = schema.NewStructValidator() + suite.configuration.EncryptionKey = testEncryptionKey suite.configuration.Local = &schema.LocalStorageConfiguration{ Path: "/this/is/a/path", } @@ -106,6 +107,26 @@ func (suite *StorageSuite) TestShouldValidatePostgresSSLModeMustBeValid() { suite.Assert().EqualError(suite.validator.Errors()[0], "SSL mode must be 'disable', 'require', 'verify-ca', or 'verify-full'") } +func (suite *StorageSuite) TestShouldRaiseErrorOnNoEncryptionKey() { + suite.configuration.EncryptionKey = "" + + ValidateStorage(suite.configuration, suite.validator) + + suite.Assert().False(suite.validator.HasWarnings()) + suite.Require().Len(suite.validator.Errors(), 1) + suite.Assert().EqualError(suite.validator.Errors()[0], "the configuration option storage.encryption_key must be provided") +} + +func (suite *StorageSuite) TestShouldRaiseErrorOnShortEncryptionKey() { + suite.configuration.EncryptionKey = "abc" + + ValidateStorage(suite.configuration, suite.validator) + + suite.Assert().False(suite.validator.HasWarnings()) + suite.Require().Len(suite.validator.Errors(), 1) + suite.Assert().EqualError(suite.validator.Errors()[0], "the configuration option storage.encryption_key must be 20 characters or longer") +} + func TestShouldRunStorageSuite(t *testing.T) { suite.Run(t, new(StorageSuite)) } diff --git a/internal/handlers/handler_register_totp.go b/internal/handlers/handler_register_totp.go index cdd88305d..81353851d 100644 --- a/internal/handlers/handler_register_totp.go +++ b/internal/handlers/handler_register_totp.go @@ -59,7 +59,7 @@ func secondFactorTOTPIdentityFinish(ctx *middlewares.AutheliaCtx, username strin Username: username, Algorithm: otpAlgoToString(algorithm), Digits: 6, - Secret: key.Secret(), + Secret: []byte(key.Secret()), Period: key.Period(), } diff --git a/internal/handlers/handler_sign_totp_test.go b/internal/handlers/handler_sign_totp_test.go index 426c314df..7867e606d 100644 --- a/internal/handlers/handler_sign_totp_test.go +++ b/internal/handlers/handler_sign_totp_test.go @@ -38,7 +38,7 @@ func (s *HandlerSignTOTPSuite) TearDownTest() { func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() { verifier := NewMockTOTPVerifier(s.mock.Ctrl) - config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: "secret", Period: 30, Algorithm: "SHA1"} + config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: []byte("secret"), Period: 30, Algorithm: "SHA1"} s.mock.StorageProviderMock.EXPECT(). LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). @@ -65,7 +65,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() { func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() { verifier := NewMockTOTPVerifier(s.mock.Ctrl) - config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: "secret", Period: 30, Algorithm: "SHA1"} + config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: []byte("secret"), Period: 30, Algorithm: "SHA1"} s.mock.StorageProviderMock.EXPECT(). LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). @@ -88,7 +88,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() { func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() { verifier := NewMockTOTPVerifier(s.mock.Ctrl) - config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: "secret", Period: 30, Algorithm: "SHA1"} + config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: []byte("secret"), Period: 30, Algorithm: "SHA1"} s.mock.StorageProviderMock.EXPECT(). LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). @@ -116,10 +116,10 @@ func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() { s.mock.StorageProviderMock.EXPECT(). LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). - Return(&models.TOTPConfiguration{Secret: "secret"}, nil) + Return(&models.TOTPConfiguration{Secret: []byte("secret")}, nil) verifier.EXPECT(). - Verify(gomock.Eq(&models.TOTPConfiguration{Secret: "secret"}), gomock.Eq("abc")). + Verify(gomock.Eq(&models.TOTPConfiguration{Secret: []byte("secret")}), gomock.Eq("abc")). Return(true, nil) bodyBytes, err := json.Marshal(signTOTPRequestBody{ @@ -136,7 +136,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() { func (s *HandlerSignTOTPSuite) TestShouldRegenerateSessionForPreventingSessionFixation() { verifier := NewMockTOTPVerifier(s.mock.Ctrl) - config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: "secret", Period: 30, Algorithm: "SHA1"} + config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: []byte("secret"), Period: 30, Algorithm: "SHA1"} s.mock.StorageProviderMock.EXPECT(). LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). diff --git a/internal/handlers/totp.go b/internal/handlers/totp.go index 510292d68..817b7ab8e 100644 --- a/internal/handlers/totp.go +++ b/internal/handlers/totp.go @@ -34,7 +34,7 @@ func (tv *TOTPVerifierImpl) Verify(config *models.TOTPConfiguration, token strin Algorithm: otpStringToAlgo(config.Algorithm), } - return totp.ValidateCustom(token, config.Secret, time.Now().UTC(), opts) + return totp.ValidateCustom(token, string(config.Secret), time.Now().UTC(), opts) } func otpAlgoToString(algorithm otp.Algorithm) (out string) { diff --git a/internal/models/model_totp_configuration.go b/internal/models/model_totp_configuration.go index b274689dc..decb361d2 100644 --- a/internal/models/model_totp_configuration.go +++ b/internal/models/model_totp_configuration.go @@ -7,5 +7,5 @@ type TOTPConfiguration struct { Algorithm string `db:"algorithm"` Digits int `db:"digits"` Period uint64 `db:"totp_period"` - Secret string `db:"secret"` + Secret []byte `db:"secret"` } diff --git a/internal/storage/const.go b/internal/storage/const.go index c0cbd73d8..ad2e2ed22 100644 --- a/internal/storage/const.go +++ b/internal/storage/const.go @@ -12,10 +12,15 @@ const ( tableDUODevices = "duo_devices" tableAuthenticationLogs = "authentication_logs" tableMigrations = "migrations" + tableEncryption = "encryption" tablePrefixBackup = "_bkp_" ) +const ( + encryptionNameCheck = "check" +) + // WARNING: Do not change/remove these consts. They are used for Pre1 migrations. const ( tablePre1TOTPSecrets = "totp_secrets" diff --git a/internal/storage/errors.go b/internal/storage/errors.go index d84a2bd66..2b26f6c7c 100644 --- a/internal/storage/errors.go +++ b/internal/storage/errors.go @@ -8,17 +8,31 @@ var ( // ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB. ErrNoU2FDeviceHandle = errors.New("no U2F device handle found") + // ErrNoAuthenticationLogs error thrown when no matching authentication logs hve been found in DB. + ErrNoAuthenticationLogs = errors.New("no matching authentication logs found") + // ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB. ErrNoTOTPSecret = errors.New("no TOTP secret registered") // ErrNoAvailableMigrations is returned when no available migrations can be found. ErrNoAvailableMigrations = errors.New("no available migrations") + // ErrMigrateCurrentVersionSameAsTarget is returned when the target version is the same as the current. + ErrMigrateCurrentVersionSameAsTarget = errors.New("current version is same as migration target, no action being taken") + // ErrSchemaAlreadyUpToDate is returned when the schema is already up to date. ErrSchemaAlreadyUpToDate = errors.New("schema already up to date") // ErrNoMigrationsFound is returned when no migrations were found. ErrNoMigrationsFound = errors.New("no schema migrations found") + + // ErrSchemaEncryptionVersionUnsupported is returned when the schema is checked if the encryption key is valid for + // the database but the schema doesn't support encryption. + ErrSchemaEncryptionVersionUnsupported = errors.New("schema version doesn't support encryption") + + // ErrSchemaEncryptionInvalidKey is returned when the schema is checked if the encryption key is valid for + // the database but the key doesn't appear to be valid. + ErrSchemaEncryptionInvalidKey = errors.New("the encryption key is not valid against the schema check value") ) // Error formats for the storage provider. diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 4a8427abe..f72225432 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -85,7 +85,7 @@ func loadMigration(providerName string, version int, up bool) (migration *Schema // this indicates the database zero state. func loadMigrations(providerName string, prior, target int) (migrations []SchemaMigration, err error) { if prior == target && (prior != -1 || target != -1) { - return nil, errors.New("cannot migrate to the same version as prior") + return nil, ErrMigrateCurrentVersionSameAsTarget } entries, err := migrationsFS.ReadDir("migrations") diff --git a/internal/storage/migrations/V0001.Initial_Schema.all.down.sql b/internal/storage/migrations/V0001.Initial_Schema.all.down.sql index 1fef4a536..fdee524ce 100644 --- a/internal/storage/migrations/V0001.Initial_Schema.all.down.sql +++ b/internal/storage/migrations/V0001.Initial_Schema.all.down.sql @@ -4,3 +4,4 @@ DROP TABLE IF EXISTS totp_configurations; DROP TABLE IF EXISTS u2f_devices; DROP TABLE IF EXISTS user_preferences; DROP TABLE IF EXISTS migrations; +DROP TABLE IF EXISTS encryption; \ No newline at end of file diff --git a/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql b/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql index 858959b5b..e7b1ca848 100644 --- a/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql +++ b/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS totp_configurations ( algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', digits INTEGER NOT NULL DEFAULT 6, totp_period INTEGER NOT NULL DEFAULT 30, - secret VARCHAR(64) NOT NULL, + secret BLOB NOT NULL, PRIMARY KEY (id), UNIQUE KEY (username) ); @@ -53,3 +53,11 @@ CREATE TABLE IF NOT EXISTS migrations ( application_version VARCHAR(128) NOT NULL, PRIMARY KEY (id) ); + +CREATE TABLE IF NOT EXISTS encryption ( + id INTEGER AUTO_INCREMENT, + name VARCHAR(100), + value BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY (name) +); diff --git a/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql b/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql index ade06ccfb..467368de3 100644 --- a/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql +++ b/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS totp_configurations ( algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', digits INTEGER NOT NULL DEFAULT 6, totp_period INTEGER NOT NULL DEFAULT 30, - secret VARCHAR(64) NOT NULL, + secret BYTEA NOT NULL, PRIMARY KEY (id), UNIQUE (username) ); @@ -53,3 +53,11 @@ CREATE TABLE IF NOT EXISTS migrations ( application_version VARCHAR(128) NOT NULL, PRIMARY KEY (id) ); + +CREATE TABLE IF NOT EXISTS encryption ( + id SERIAL, + name VARCHAR(100), + value BYTEA NOT NULL, + PRIMARY KEY (id), + UNIQUE (name) +); \ No newline at end of file diff --git a/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql b/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql index 139d45853..2a0b5e6bd 100644 --- a/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql +++ b/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS totp_configurations ( algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', digits INTEGER(1) NOT NULL DEFAULT 6, totp_period INTEGER NOT NULL DEFAULT 30, - secret VARCHAR(64) NOT NULL, + secret BLOB NOT NULL, PRIMARY KEY (id), UNIQUE (username) ); @@ -52,3 +52,11 @@ CREATE TABLE IF NOT EXISTS migrations ( application_version VARCHAR(128) NOT NULL, PRIMARY KEY (id) ); + +CREATE TABLE IF NOT EXISTS encryption ( + id INTEGER, + name VARCHAR(100), + value BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE (name) +); diff --git a/internal/storage/provider.go b/internal/storage/provider.go index d95881253..1e9ff421a 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -24,6 +24,8 @@ type Provider interface { SaveTOTPConfiguration(ctx context.Context, config models.TOTPConfiguration) (err error) DeleteTOTPConfiguration(ctx context.Context, username string) (err error) LoadTOTPConfiguration(ctx context.Context, username string) (config *models.TOTPConfiguration, err error) + LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []models.TOTPConfiguration, err error) + UpdateTOTPConfigurationSecret(ctx context.Context, config models.TOTPConfiguration) (err error) SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error) LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error) @@ -33,9 +35,14 @@ type Provider interface { SchemaMigrate(ctx context.Context, up bool, version int) (err error) SchemaMigrationHistory(ctx context.Context) (migrations []models.Migration, err error) + SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error) + SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (err error) + SchemaLatestVersion() (version int, err error) SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error) SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error) + + Close() (err error) } // RegulatorProvider is an interface providing storage capabilities for persisting any kind of data related to the regulator. diff --git a/internal/storage/provider_mock.go b/internal/storage/provider_mock.go index d9a773ab9..4c3fb681f 100644 --- a/internal/storage/provider_mock.go +++ b/internal/storage/provider_mock.go @@ -4,13 +4,13 @@ package storage import ( - context "context" - reflect "reflect" - time "time" + "context" + "reflect" + "time" - gomock "github.com/golang/mock/gomock" + "github.com/golang/mock/gomock" - models "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/models" ) // MockProvider is a mock of Provider interface. @@ -50,6 +50,20 @@ func (mr *MockProviderMockRecorder) AppendAuthenticationLog(arg0, arg1 interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendAuthenticationLog", reflect.TypeOf((*MockProvider)(nil).AppendAuthenticationLog), arg0, arg1) } +// Close mocks base method. +func (m *MockProvider) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockProviderMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockProvider)(nil).Close)) +} + // DeleteTOTPConfiguration mocks base method. func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() @@ -124,6 +138,21 @@ func (mr *MockProviderMockRecorder) LoadTOTPConfiguration(arg0, arg1 interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadTOTPConfiguration", reflect.TypeOf((*MockProvider)(nil).LoadTOTPConfiguration), arg0, arg1) } +// LoadTOTPConfigurations mocks base method. +func (m *MockProvider) LoadTOTPConfigurations(arg0 context.Context, arg1, arg2 int) ([]models.TOTPConfiguration, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadTOTPConfigurations", arg0, arg1, arg2) + ret0, _ := ret[0].([]models.TOTPConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadTOTPConfigurations indicates an expected call of LoadTOTPConfigurations. +func (mr *MockProviderMockRecorder) LoadTOTPConfigurations(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadTOTPConfigurations", reflect.TypeOf((*MockProvider)(nil).LoadTOTPConfigurations), arg0, arg1, arg2) +} + // LoadU2FDevice mocks base method. func (m *MockProvider) LoadU2FDevice(arg0 context.Context, arg1 string) (*models.U2FDevice, error) { m.ctrl.T.Helper() @@ -224,6 +253,34 @@ func (mr *MockProviderMockRecorder) SaveU2FDevice(arg0, arg1 interface{}) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveU2FDevice", reflect.TypeOf((*MockProvider)(nil).SaveU2FDevice), arg0, arg1) } +// SchemaEncryptionChangeKey mocks base method. +func (m *MockProvider) SchemaEncryptionChangeKey(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchemaEncryptionChangeKey", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SchemaEncryptionChangeKey indicates an expected call of SchemaEncryptionChangeKey. +func (mr *MockProviderMockRecorder) SchemaEncryptionChangeKey(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchemaEncryptionChangeKey", reflect.TypeOf((*MockProvider)(nil).SchemaEncryptionChangeKey), arg0, arg1) +} + +// SchemaEncryptionCheckKey mocks base method. +func (m *MockProvider) SchemaEncryptionCheckKey(arg0 context.Context, arg1 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SchemaEncryptionCheckKey", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SchemaEncryptionCheckKey indicates an expected call of SchemaEncryptionCheckKey. +func (mr *MockProviderMockRecorder) SchemaEncryptionCheckKey(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchemaEncryptionCheckKey", reflect.TypeOf((*MockProvider)(nil).SchemaEncryptionCheckKey), arg0, arg1) +} + // SchemaLatestVersion mocks base method. func (m *MockProvider) SchemaLatestVersion() (int, error) { m.ctrl.T.Helper() @@ -341,3 +398,17 @@ func (mr *MockProviderMockRecorder) StartupCheck() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartupCheck", reflect.TypeOf((*MockProvider)(nil).StartupCheck)) } + +// UpdateTOTPConfigurationSecret mocks base method. +func (m *MockProvider) UpdateTOTPConfigurationSecret(arg0 context.Context, arg1 models.TOTPConfiguration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTOTPConfigurationSecret", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTOTPConfigurationSecret indicates an expected call of UpdateTOTPConfigurationSecret. +func (mr *MockProviderMockRecorder) UpdateTOTPConfigurationSecret(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTOTPConfigurationSecret", reflect.TypeOf((*MockProvider)(nil).UpdateTOTPConfigurationSecret), arg0, arg1) +} diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go index f136e4c6e..9ff5dad2b 100644 --- a/internal/storage/sql_provider.go +++ b/internal/storage/sql_provider.go @@ -2,6 +2,7 @@ package storage import ( "context" + "crypto/sha256" "database/sql" "errors" "fmt" @@ -16,15 +17,16 @@ import ( ) // NewSQLProvider generates a generic SQLProvider to be used with other SQL provider NewUp's. -func NewSQLProvider(name, driverName, dataSourceName string) (provider SQLProvider) { +func NewSQLProvider(name, driverName, dataSourceName, encryptionKey string) (provider SQLProvider) { db, err := sqlx.Open(driverName, dataSourceName) provider = SQLProvider{ + db: db, + key: sha256.Sum256([]byte(encryptionKey)), name: name, driverName: driverName, - db: db, - log: logging.Logger(), errOpen: err, + log: logging.Logger(), sqlInsertAuthenticationAttempt: fmt.Sprintf(queryFmtInsertAuthenticationLogEntry, tableAuthenticationLogs), sqlSelectAuthenticationAttemptsByUsername: fmt.Sprintf(queryFmtSelect1FAAuthenticationLogEntryByUsername, tableAuthenticationLogs), @@ -33,9 +35,13 @@ func NewSQLProvider(name, driverName, dataSourceName string) (provider SQLProvid sqlDeleteIdentityVerification: fmt.Sprintf(queryFmtDeleteIdentityVerification, tableIdentityVerification), sqlSelectExistsIdentityVerification: fmt.Sprintf(queryFmtSelectExistsIdentityVerification, tableIdentityVerification), - sqlUpsertTOTPConfig: fmt.Sprintf(queryFmtUpsertTOTPConfiguration, tableTOTPConfigurations), - sqlDeleteTOTPConfig: fmt.Sprintf(queryFmtDeleteTOTPConfiguration, tableTOTPConfigurations), - sqlSelectTOTPConfig: fmt.Sprintf(queryFmtSelectTOTPConfiguration, tableTOTPConfigurations), + sqlUpsertTOTPConfig: fmt.Sprintf(queryFmtUpsertTOTPConfiguration, tableTOTPConfigurations), + sqlDeleteTOTPConfig: fmt.Sprintf(queryFmtDeleteTOTPConfiguration, tableTOTPConfigurations), + sqlSelectTOTPConfig: fmt.Sprintf(queryFmtSelectTOTPConfiguration, tableTOTPConfigurations), + sqlSelectTOTPConfigs: fmt.Sprintf(queryFmtSelectTOTPConfigurations, tableTOTPConfigurations), + + sqlUpdateTOTPConfigSecret: fmt.Sprintf(queryFmtUpdateTOTPConfigurationSecret, tableTOTPConfigurations), + sqlUpdateTOTPConfigSecretByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigurationSecretByUsername, tableTOTPConfigurations), sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices), sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, tableU2FDevices), @@ -48,20 +54,29 @@ func NewSQLProvider(name, driverName, dataSourceName string) (provider SQLProvid sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations), sqlSelectLatestMigration: fmt.Sprintf(queryFmtSelectLatestMigration, tableMigrations), + sqlUpsertEncryptionValue: fmt.Sprintf(queryFmtUpsertEncryptionValue, tableEncryption), + sqlSelectEncryptionValue: fmt.Sprintf(queryFmtSelectEncryptionValue, tableEncryption), + sqlFmtRenameTable: queryFmtRenameTable, } + key := sha256.Sum256([]byte(encryptionKey)) + + provider.key = key + return provider } // SQLProvider is a storage provider persisting data in a SQL database. type SQLProvider struct { db *sqlx.DB - log *logrus.Logger + key [32]byte name string driverName string errOpen error + log *logrus.Logger + // Table: authentication_logs. sqlInsertAuthenticationAttempt string sqlSelectAuthenticationAttemptsByUsername string @@ -72,9 +87,13 @@ type SQLProvider struct { sqlSelectExistsIdentityVerification string // Table: totp_configurations. - sqlUpsertTOTPConfig string - sqlDeleteTOTPConfig string - sqlSelectTOTPConfig string + sqlUpsertTOTPConfig string + sqlDeleteTOTPConfig string + sqlSelectTOTPConfig string + sqlSelectTOTPConfigs string + + sqlUpdateTOTPConfigSecret string + sqlUpdateTOTPConfigSecretByUsername string // Table: u2f_devices. sqlUpsertU2FDevice string @@ -90,21 +109,29 @@ type SQLProvider struct { sqlSelectMigrations string sqlSelectLatestMigration string + // Table: encryption. + sqlUpsertEncryptionValue string + sqlSelectEncryptionValue string + // Utility. sqlSelectExistingTables string sqlFmtRenameTable string } +// Close the underlying database connection. +func (p *SQLProvider) Close() (err error) { + return p.db.Close() +} + // StartupCheck implements the provider startup check interface. func (p *SQLProvider) StartupCheck() (err error) { if p.errOpen != nil { - return p.errOpen + return fmt.Errorf("error opening database: %w", p.errOpen) } // TODO: Decide if this is needed, or if it should be configurable. for i := 0; i < 19; i++ { - err = p.db.Ping() - if err == nil { + if err = p.db.Ping(); err == nil { break } @@ -112,13 +139,17 @@ func (p *SQLProvider) StartupCheck() (err error) { } if err != nil { - return err + return fmt.Errorf("error pinging database: %w", err) } p.log.Infof("Storage schema is being checked for updates") ctx := context.Background() + if err = p.SchemaEncryptionCheckKey(ctx, false); err != nil && !errors.Is(err, ErrSchemaEncryptionVersionUnsupported) { + return err + } + err = p.SchemaMigrate(ctx, true, SchemaLatest) switch err { @@ -128,7 +159,7 @@ func (p *SQLProvider) StartupCheck() (err error) { case nil: return nil default: - return err + return fmt.Errorf("error during schema migrate: %w", err) } } @@ -143,13 +174,13 @@ func (p *SQLProvider) SavePreferred2FAMethod(ctx context.Context, username strin func (p *SQLProvider) LoadPreferred2FAMethod(ctx context.Context, username string) (method string, err error) { err = p.db.GetContext(ctx, &method, p.sqlSelectPreferred2FAMethod, username) - switch err { - case sql.ErrNoRows: + switch { + case err == nil: + return method, nil + case errors.Is(err, sql.ErrNoRows): return "", nil - case nil: - return method, err default: - return "", err + return "", fmt.Errorf("error selecting preferred two factor method for user '%s': %w", username, err) } } @@ -161,120 +192,98 @@ func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info m case err == nil: return info, nil case errors.Is(err, sql.ErrNoRows): - _, err = p.db.ExecContext(ctx, p.sqlUpsertPreferred2FAMethod, username, authentication.PossibleMethods[0]) - if err != nil { - return models.UserInfo{}, err + if _, err = p.db.ExecContext(ctx, p.sqlUpsertPreferred2FAMethod, username, authentication.PossibleMethods[0]); err != nil { + return models.UserInfo{}, fmt.Errorf("error upserting preferred two factor method while selecting user info for user '%s': %w", username, err) } - err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username) - if err != nil { - return models.UserInfo{}, err + if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username); err != nil { + return models.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err) } return info, nil default: - return models.UserInfo{}, err + return models.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err) } } // SaveIdentityVerification save an identity verification record to the database. func (p *SQLProvider) SaveIdentityVerification(ctx context.Context, verification models.IdentityVerification) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlInsertIdentityVerification, verification.Token) + if _, err = p.db.ExecContext(ctx, p.sqlInsertIdentityVerification, verification.Token); err != nil { + return fmt.Errorf("error inserting identity verification: %w", err) + } - return err + return nil } // RemoveIdentityVerification remove an identity verification record from the database. func (p *SQLProvider) RemoveIdentityVerification(ctx context.Context, token string) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlDeleteIdentityVerification, token) + if _, err = p.db.ExecContext(ctx, p.sqlDeleteIdentityVerification, token); err != nil { + return fmt.Errorf("error updating identity verification: %w", err) + } - return err + return nil } // FindIdentityVerification checks if an identity verification record is in the database and active. -func (p *SQLProvider) FindIdentityVerification(ctx context.Context, jti string) (found bool, err error) { - err = p.db.GetContext(ctx, &found, p.sqlSelectExistsIdentityVerification, jti) - if err != nil { - return false, err +func (p *SQLProvider) FindIdentityVerification(ctx context.Context, token string) (found bool, err error) { + if err = p.db.GetContext(ctx, &found, p.sqlSelectExistsIdentityVerification, token); err != nil { + return false, fmt.Errorf("error selecting identity verification exists: %w", err) } return found, nil } -// SaveTOTPConfiguration save a TOTP config of a given user in the database. +// SaveTOTPConfiguration save a TOTP configuration of a given user in the database. func (p *SQLProvider) SaveTOTPConfiguration(ctx context.Context, config models.TOTPConfiguration) (err error) { - // TODO: Encrypt config.Secret here. - _, err = p.db.ExecContext(ctx, p.sqlUpsertTOTPConfig, - config.Username, - config.Algorithm, - config.Digits, - config.Period, - config.Secret, - ) + if config.Secret, err = p.encrypt(config.Secret); err != nil { + return fmt.Errorf("error encrypting the TOTP configuration secret: %v", err) + } - return err + if _, err = p.db.ExecContext(ctx, p.sqlUpsertTOTPConfig, + config.Username, config.Algorithm, config.Digits, config.Period, config.Secret); err != nil { + return fmt.Errorf("error upserting TOTP configuration: %w", err) + } + + return nil } -// DeleteTOTPConfiguration delete a TOTP secret from the database given a username. +// DeleteTOTPConfiguration delete a TOTP configuration from the database given a username. func (p *SQLProvider) DeleteTOTPConfiguration(ctx context.Context, username string) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlDeleteTOTPConfig, username) + if _, err = p.db.ExecContext(ctx, p.sqlDeleteTOTPConfig, username); err != nil { + return fmt.Errorf("error deleting TOTP configuration: %w", err) + } - return err + return nil } -// LoadTOTPConfiguration load a TOTP secret given a username from the database. +// LoadTOTPConfiguration load a TOTP configuration given a username from the database. func (p *SQLProvider) LoadTOTPConfiguration(ctx context.Context, username string) (config *models.TOTPConfiguration, err error) { config = &models.TOTPConfiguration{} - err = p.db.QueryRowxContext(ctx, p.sqlSelectTOTPConfig, username).StructScan(config) - if err != nil { - if err == sql.ErrNoRows { + if err = p.db.QueryRowxContext(ctx, p.sqlSelectTOTPConfig, username).StructScan(config); err != nil { + if errors.Is(err, sql.ErrNoRows) { return nil, ErrNoTOTPSecret } - return nil, err + return nil, fmt.Errorf("error selecting TOTP configuration: %w", err) + } + + if config.Secret, err = p.decrypt(config.Secret); err != nil { + return nil, fmt.Errorf("error decrypting the TOTP secret: %v", err) } - // TODO: Decrypt config.Secret here. return config, nil } -// SaveU2FDevice saves a registered U2F device. -func (p *SQLProvider) SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlUpsertU2FDevice, device.Username, device.KeyHandle, device.PublicKey) - - return err -} - -// LoadU2FDevice loads a U2F device registration for a given username. -func (p *SQLProvider) LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error) { - device = &models.U2FDevice{ - Username: username, - } - - err = p.db.GetContext(ctx, device, p.sqlSelectU2FDevice, username) +// LoadTOTPConfigurations load a set of TOTP configurations. +func (p *SQLProvider) LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []models.TOTPConfiguration, err error) { + rows, err := p.db.QueryxContext(ctx, p.sqlSelectTOTPConfigs, limit, limit*page) if err != nil { - if err == sql.ErrNoRows { - return nil, ErrNoU2FDeviceHandle + if errors.Is(err, sql.ErrNoRows) { + return configs, nil } - return nil, err - } - - return device, nil -} - -// AppendAuthenticationLog append a mark to the authentication log. -func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt, attempt.Time, attempt.Successful, attempt.Username) - return err -} - -// LoadAuthenticationLogs retrieve the latest failed authentications from the authentication log. -func (p *SQLProvider) LoadAuthenticationLogs(ctx context.Context, username string, fromDate time.Time, limit, page int) (attempts []models.AuthenticationAttempt, err error) { - rows, err := p.db.QueryxContext(ctx, p.sqlSelectAuthenticationAttemptsByUsername, fromDate, username, limit, limit*page) - if err != nil { - return nil, err + return nil, fmt.Errorf("error selecting TOTP configurations: %w", err) } defer func() { @@ -283,13 +292,99 @@ func (p *SQLProvider) LoadAuthenticationLogs(ctx context.Context, username strin } }() + configs = make([]models.TOTPConfiguration, 0, limit) + + var config models.TOTPConfiguration + + for rows.Next() { + if err = rows.StructScan(&config); err != nil { + return nil, fmt.Errorf("error scanning TOTP configuration to struct: %w", err) + } + + if config.Secret, err = p.decrypt(config.Secret); err != nil { + return nil, fmt.Errorf("error decrypting the TOTP secret: %v", err) + } + + configs = append(configs, config) + } + + return configs, nil +} + +// UpdateTOTPConfigurationSecret updates a TOTP configuration secret. +func (p *SQLProvider) UpdateTOTPConfigurationSecret(ctx context.Context, config models.TOTPConfiguration) (err error) { + switch config.ID { + case 0: + _, err = p.db.ExecContext(ctx, p.sqlUpdateTOTPConfigSecretByUsername, config.Secret, config.Username) + default: + _, err = p.db.ExecContext(ctx, p.sqlUpdateTOTPConfigSecret, config.Secret, config.ID) + } + + if err != nil { + return fmt.Errorf("error updating TOTP configuration secret: %w", err) + } + + return nil +} + +// SaveU2FDevice saves a registered U2F device. +func (p *SQLProvider) SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error) { + if _, err = p.db.ExecContext(ctx, p.sqlUpsertU2FDevice, device.Username, device.KeyHandle, device.PublicKey); err != nil { + return fmt.Errorf("error upserting U2F device secret: %v", err) + } + + return nil +} + +// LoadU2FDevice loads a U2F device registration for a given username. +func (p *SQLProvider) LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error) { + device = &models.U2FDevice{ + Username: username, + } + + if err = p.db.GetContext(ctx, device, p.sqlSelectU2FDevice, username); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNoU2FDeviceHandle + } + + return nil, fmt.Errorf("error selecting U2F device: %w", err) + } + + return device, nil +} + +// AppendAuthenticationLog append a mark to the authentication log. +func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) { + if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt, attempt.Time, attempt.Successful, attempt.Username); err != nil { + return fmt.Errorf("error inserting authentiation attempt: %w", err) + } + + return nil +} + +// LoadAuthenticationLogs retrieve the latest failed authentications from the authentication log. +func (p *SQLProvider) LoadAuthenticationLogs(ctx context.Context, username string, fromDate time.Time, limit, page int) (attempts []models.AuthenticationAttempt, err error) { + rows, err := p.db.QueryxContext(ctx, p.sqlSelectAuthenticationAttemptsByUsername, fromDate, username, limit, limit*page) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNoAuthenticationLogs + } + + return nil, fmt.Errorf("error selecting authentication logs: %w", err) + } + + defer func() { + if err := rows.Close(); err != nil { + p.log.Errorf(logFmtErrClosingConn, err) + } + }() + + var attempt models.AuthenticationAttempt + attempts = make([]models.AuthenticationAttempt, 0, limit) for rows.Next() { - var attempt models.AuthenticationAttempt - - err = rows.StructScan(&attempt) - if err != nil { + if err = rows.StructScan(&attempt); err != nil { return nil, err } diff --git a/internal/storage/sql_provider_backend_mysql.go b/internal/storage/sql_provider_backend_mysql.go index dfb7ec179..8805aea21 100644 --- a/internal/storage/sql_provider_backend_mysql.go +++ b/internal/storage/sql_provider_backend_mysql.go @@ -15,9 +15,9 @@ type MySQLProvider struct { } // NewMySQLProvider a MySQL provider. -func NewMySQLProvider(config schema.MySQLStorageConfiguration) (provider *MySQLProvider) { +func NewMySQLProvider(config schema.MySQLStorageConfiguration, encryptionKey string) (provider *MySQLProvider) { provider = &MySQLProvider{ - SQLProvider: NewSQLProvider(providerMySQL, providerMySQL, dataSourceNameMySQL(config)), + SQLProvider: NewSQLProvider(providerMySQL, providerMySQL, dataSourceNameMySQL(config), encryptionKey), } // All providers have differing SELECT existing table statements. diff --git a/internal/storage/sql_provider_backend_postgres.go b/internal/storage/sql_provider_backend_postgres.go index 5a79afe84..8b59367a7 100644 --- a/internal/storage/sql_provider_backend_postgres.go +++ b/internal/storage/sql_provider_backend_postgres.go @@ -16,9 +16,9 @@ type PostgreSQLProvider struct { } // NewPostgreSQLProvider a PostgreSQL provider. -func NewPostgreSQLProvider(config schema.PostgreSQLStorageConfiguration) (provider *PostgreSQLProvider) { +func NewPostgreSQLProvider(config schema.PostgreSQLStorageConfiguration, encryptionKey string) (provider *PostgreSQLProvider) { provider = &PostgreSQLProvider{ - SQLProvider: NewSQLProvider(providerPostgres, "pgx", dataSourceNamePostgreSQL(config)), + SQLProvider: NewSQLProvider(providerPostgres, "pgx", dataSourceNamePostgreSQL(config), encryptionKey), } // All providers have differing SELECT existing table statements. @@ -29,6 +29,7 @@ func NewPostgreSQLProvider(config schema.PostgreSQLStorageConfiguration) (provid provider.sqlUpsertU2FDevice = fmt.Sprintf(queryFmtPostgresUpsertU2FDevice, tableU2FDevices) provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtPostgresUpsertTOTPConfiguration, tableTOTPConfigurations) provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtPostgresUpsertPreferred2FAMethod, tableUserPreferences) + provider.sqlUpsertEncryptionValue = fmt.Sprintf(queryFmtPostgresUpsertEncryptionValue, tableEncryption) // PostgreSQL requires rebinding of any query that contains a '?' placeholder to use the '$#' notation placeholders. provider.sqlFmtRenameTable = provider.db.Rebind(provider.sqlFmtRenameTable) @@ -40,10 +41,14 @@ func NewPostgreSQLProvider(config schema.PostgreSQLStorageConfiguration) (provid provider.sqlSelectTOTPConfig = provider.db.Rebind(provider.sqlSelectTOTPConfig) provider.sqlUpsertTOTPConfig = provider.db.Rebind(provider.sqlUpsertTOTPConfig) provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig) + provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs) + provider.sqlUpdateTOTPConfigSecret = provider.db.Rebind(provider.sqlUpdateTOTPConfigSecret) + provider.sqlUpdateTOTPConfigSecretByUsername = provider.db.Rebind(provider.sqlUpdateTOTPConfigSecretByUsername) provider.sqlSelectU2FDevice = provider.db.Rebind(provider.sqlSelectU2FDevice) provider.sqlInsertAuthenticationAttempt = provider.db.Rebind(provider.sqlInsertAuthenticationAttempt) provider.sqlSelectAuthenticationAttemptsByUsername = provider.db.Rebind(provider.sqlSelectAuthenticationAttemptsByUsername) provider.sqlInsertMigration = provider.db.Rebind(provider.sqlInsertMigration) + provider.sqlSelectEncryptionValue = provider.db.Rebind(provider.sqlSelectEncryptionValue) return provider } diff --git a/internal/storage/sql_provider_backend_sqlite.go b/internal/storage/sql_provider_backend_sqlite.go index b11b82239..f54309b06 100644 --- a/internal/storage/sql_provider_backend_sqlite.go +++ b/internal/storage/sql_provider_backend_sqlite.go @@ -10,9 +10,9 @@ type SQLiteProvider struct { } // NewSQLiteProvider constructs a SQLite provider. -func NewSQLiteProvider(path string) (provider *SQLiteProvider) { +func NewSQLiteProvider(path, encryptionKey string) (provider *SQLiteProvider) { provider = &SQLiteProvider{ - SQLProvider: NewSQLProvider(providerSQLite, "sqlite3", path), + SQLProvider: NewSQLProvider(providerSQLite, "sqlite3", path, encryptionKey), } // All providers have differing SELECT existing table statements. diff --git a/internal/storage/sql_provider_encryption.go b/internal/storage/sql_provider_encryption.go new file mode 100644 index 000000000..b7b2d1172 --- /dev/null +++ b/internal/storage/sql_provider_encryption.go @@ -0,0 +1,185 @@ +package storage + +import ( + "context" + "crypto/sha256" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/utils" +) + +// SchemaEncryptionChangeKey uses the currently configured key to decrypt values in the database and the key provided +// by this command to encrypt the values again and update them using a transaction. +func (p *SQLProvider) SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error) { + tx, err := p.db.Beginx() + if err != nil { + return fmt.Errorf("error beginning transaction to change encryption key: %w", err) + } + + key := sha256.Sum256([]byte(encryptionKey)) + + var configs []models.TOTPConfiguration + + for page := 0; true; page++ { + if configs, err = p.LoadTOTPConfigurations(ctx, 10, page); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("rollback error %v: rollback due to error: %w", rollbackErr, err) + } + + return fmt.Errorf("rollback due to error: %w", err) + } + + for _, config := range configs { + if config.Secret, err = utils.Encrypt(config.Secret, &key); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("rollback error %v: rollback due to error: %w", rollbackErr, err) + } + + return fmt.Errorf("rollback due to error: %w", err) + } + + if err = p.UpdateTOTPConfigurationSecret(ctx, config); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("rollback error %v: rollback due to error: %w", rollbackErr, err) + } + + return fmt.Errorf("rollback due to error: %w", err) + } + } + + if len(configs) != 10 { + break + } + } + + if err = p.setNewEncryptionCheckValue(ctx, &key, tx); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("rollback error %v: rollback due to error: %w", rollbackErr, err) + } + + return fmt.Errorf("rollback due to error: %w", err) + } + + return tx.Commit() +} + +// SchemaEncryptionCheckKey checks the encryption key configured is valid for the database. +func (p *SQLProvider) SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (err error) { + version, err := p.SchemaVersion(ctx) + if err != nil { + return err + } + + if version < 1 { + return ErrSchemaEncryptionVersionUnsupported + } + + var errs []error + + if _, err = p.getEncryptionValue(ctx, encryptionNameCheck); err != nil { + errs = append(errs, ErrSchemaEncryptionInvalidKey) + } + + if verbose { + var ( + config models.TOTPConfiguration + row int + invalid int + total int + ) + + pageSize := 10 + + var rows *sqlx.Rows + + for page := 0; true; page++ { + if rows, err = p.db.QueryxContext(ctx, p.sqlSelectTOTPConfigs, pageSize, pageSize*page); err != nil { + _ = rows.Close() + + return fmt.Errorf("error selecting TOTP configurations: %w", err) + } + + row = 0 + + for rows.Next() { + total++ + row++ + + if err = rows.StructScan(&config); err != nil { + _ = rows.Close() + return fmt.Errorf("error scanning TOTP configuration to struct: %w", err) + } + + if _, err = p.decrypt(config.Secret); err != nil { + invalid++ + } + } + + _ = rows.Close() + + if row < pageSize { + break + } + } + + if invalid != 0 { + errs = append(errs, fmt.Errorf("%d of %d total TOTP secrets were invalid", invalid, total)) + } + } + + if len(errs) != 0 { + for i, e := range errs { + if i == 0 { + err = e + + continue + } + + err = fmt.Errorf("%w, %v", err, e) + } + + return err + } + + return nil +} + +func (p SQLProvider) encrypt(clearText []byte) (cipherText []byte, err error) { + return utils.Encrypt(clearText, &p.key) +} + +func (p SQLProvider) decrypt(cipherText []byte) (clearText []byte, err error) { + return utils.Decrypt(cipherText, &p.key) +} + +func (p *SQLProvider) getEncryptionValue(ctx context.Context, name string) (value []byte, err error) { + var encryptedValue []byte + + err = p.db.GetContext(ctx, &encryptedValue, p.sqlSelectEncryptionValue, name) + if err != nil { + return nil, err + } + + return p.decrypt(encryptedValue) +} + +func (p *SQLProvider) setNewEncryptionCheckValue(ctx context.Context, key *[32]byte, e sqlx.ExecerContext) (err error) { + valueClearText := uuid.New() + + value, err := utils.Encrypt([]byte(valueClearText.String()), key) + if err != nil { + return err + } + + if e != nil { + _, err = e.ExecContext(ctx, p.sqlUpsertEncryptionValue, encryptionNameCheck, value) + } else { + _, err = p.db.ExecContext(ctx, p.sqlUpsertEncryptionValue, encryptionNameCheck, value) + } + + return err +} diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go index f11585d7d..57c2f4687 100644 --- a/internal/storage/sql_provider_queries.go +++ b/internal/storage/sql_provider_queries.go @@ -78,6 +78,24 @@ const ( FROM %s WHERE username = ?;` + queryFmtSelectTOTPConfigurations = ` + SELECT id, username, algorithm, digits, totp_period, secret + FROM %s + LIMIT ? + OFFSET ?;` + + //nolint:gosec // These are not hardcoded credentials it's a query to obtain credentials. + queryFmtUpdateTOTPConfigurationSecret = ` + UPDATE %s + SET secret = ? + WHERE id = ?;` + + //nolint:gosec // These are not hardcoded credentials it's a query to obtain credentials. + queryFmtUpdateTOTPConfigurationSecretByUsername = ` + UPDATE %s + SET secret = ? + WHERE username = ?;` + queryFmtUpsertTOTPConfiguration = ` REPLACE INTO %s (username, algorithm, digits, totp_period, secret) VALUES (?, ?, ?, ?, ?);` @@ -123,3 +141,20 @@ const ( LIMIT ? OFFSET ?;` ) + +const ( + queryFmtSelectEncryptionValue = ` + SELECT (value) + FROM %s + WHERE name = ?` + + queryFmtUpsertEncryptionValue = ` + REPLACE INTO %s (name, value) + VALUES (?, ?);` + + queryFmtPostgresUpsertEncryptionValue = ` + INSERT INTO %s (name, value) + VALUES ($1, $2) + ON CONFLICT (name) + DO UPDATE SET value=$2;` +) diff --git a/internal/storage/sql_provider_schema.go b/internal/storage/sql_provider_schema.go index 6abb5ff58..fc97c531b 100644 --- a/internal/storage/sql_provider_schema.go +++ b/internal/storage/sql_provider_schema.go @@ -63,8 +63,6 @@ func (p *SQLProvider) SchemaVersion(ctx context.Context) (version int, err error return -1, nil } - // TODO: Decide if we want to support external tables. - // return -2, ErrUnknownSchemaState return 0, nil } @@ -213,7 +211,18 @@ func (p *SQLProvider) schemaMigrateApply(ctx context.Context, migration SchemaMi return fmt.Errorf(errFmtFailedMigration, migration.Version, migration.Name, err) } - // Skip the migration history insertion in a migration to v0. + if migration.Version == 1 { + // Skip the migration history insertion in a migration to v0. + if !migration.Up { + return nil + } + + // Add the schema encryption value if upgrading to v1. + if err = p.setNewEncryptionCheckValue(ctx, &p.key, nil); err != nil { + return err + } + } + if migration.Version == 1 && !migration.Up { return nil } diff --git a/internal/storage/sql_provider_schema_pre1.go b/internal/storage/sql_provider_schema_pre1.go index a8aad4509..a9d40e1a4 100644 --- a/internal/storage/sql_provider_schema_pre1.go +++ b/internal/storage/sql_provider_schema_pre1.go @@ -49,6 +49,10 @@ func (p *SQLProvider) schemaMigratePre1To1(ctx context.Context) (err error) { return fmt.Errorf(errFmtFailedMigration, migration.Version, migration.Name, err) } + if err = p.setNewEncryptionCheckValue(ctx, &p.key, nil); err != nil { + return err + } + if _, err = p.db.ExecContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmtPre1InsertUserPreferencesFromSelect), tableUserPreferences, tablePrefixBackup+tableUserPreferences)); err != nil { return err @@ -213,8 +217,10 @@ func (p *SQLProvider) schemaMigratePre1To1TOTP(ctx context.Context) (err error) return err } - // TODO: Add encryption migration here. - encryptedSecret := "encrypted:" + secret + encryptedSecret, err := p.encrypt([]byte(secret)) + if err != nil { + return err + } totpConfigs = append(totpConfigs, models.TOTPConfiguration{Username: username, Secret: encryptedSecret}) } @@ -288,6 +294,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1(ctx context.Context) (err error) { tableDUODevices, tableUserPreferences, tableAuthenticationLogs, + tableEncryption, } if err = p.schemaMigratePre1Rename(ctx, tables, tablesRename); err != nil { @@ -388,18 +395,22 @@ func (p *SQLProvider) schemaMigrate1ToPre1TOTP(ctx context.Context) (err error) }() for rows.Next() { - var username, encryptedSecret string + var ( + username string + secretCipherText []byte + ) - err = rows.Scan(&username, &encryptedSecret) + err = rows.Scan(&username, &secretCipherText) if err != nil { return err } - // TODO: Fix. - // TODO: Add DECRYPTION migration here. - decryptedSecret := strings.Replace(encryptedSecret, "encrypted:", "", 1) + secretClearText, err := p.decrypt(secretCipherText) + if err != nil { + return err + } - totpConfigs = append(totpConfigs, models.TOTPConfiguration{Username: username, Secret: decryptedSecret}) + totpConfigs = append(totpConfigs, models.TOTPConfiguration{Username: username, Secret: secretClearText}) } for _, config := range totpConfigs { diff --git a/internal/suites/ActiveDirectory/configuration.yml b/internal/suites/ActiveDirectory/configuration.yml index 9f8ed8439..a1adb91cd 100644 --- a/internal/suites/ActiveDirectory/configuration.yml +++ b/internal/suites/ActiveDirectory/configuration.yml @@ -37,6 +37,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite3 diff --git a/internal/suites/BypassAll/configuration.yml b/internal/suites/BypassAll/configuration.yml index 09caba0f6..25a4c5c8b 100644 --- a/internal/suites/BypassAll/configuration.yml +++ b/internal/suites/BypassAll/configuration.yml @@ -26,6 +26,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/CLI/configuration.yml b/internal/suites/CLI/configuration.yml index d4c33c5ca..de0f31cc6 100644 --- a/internal/suites/CLI/configuration.yml +++ b/internal/suites/CLI/configuration.yml @@ -26,8 +26,9 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: - path: /config/db.sqlite + path: /tmp/db.sqlite access_control: default_policy: bypass @@ -43,5 +44,5 @@ access_control: notifier: filesystem: - filename: /config/notification.txt + filename: /tmp/notification.txt ... diff --git a/internal/suites/CLI/docker-compose.yml b/internal/suites/CLI/docker-compose.yml index 5935d4a51..180ba191e 100644 --- a/internal/suites/CLI/docker-compose.yml +++ b/internal/suites/CLI/docker-compose.yml @@ -4,6 +4,9 @@ services: authelia-backend: volumes: - './CLI/configuration.yml:/config/configuration.yml:ro' + - './CLI/storage.yml:/config/configuration.storage.yml:ro' - './CLI/users.yml:/config/users.yml' - './common/ssl:/config/ssl:ro' + - '/tmp:/tmp' + user: ${USER_ID}:${GROUP_ID} ... diff --git a/internal/suites/CLI/storage.yml b/internal/suites/CLI/storage.yml new file mode 100644 index 000000000..ca6381fa9 --- /dev/null +++ b/internal/suites/CLI/storage.yml @@ -0,0 +1,6 @@ +--- +storage: + encryption_key: a_cli_encryption_key_which_isnt_secure + local: + path: /tmp/db.sqlite3 +... diff --git a/internal/suites/Docker/configuration.yml b/internal/suites/Docker/configuration.yml index 056c63ed9..5ac4aaa5b 100644 --- a/internal/suites/Docker/configuration.yml +++ b/internal/suites/Docker/configuration.yml @@ -27,6 +27,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite3 diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml index d0881199b..56a7ec8c2 100644 --- a/internal/suites/DuoPush/configuration.yml +++ b/internal/suites/DuoPush/configuration.yml @@ -28,6 +28,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/HAProxy/configuration.yml b/internal/suites/HAProxy/configuration.yml index 046c181dc..193cae03d 100644 --- a/internal/suites/HAProxy/configuration.yml +++ b/internal/suites/HAProxy/configuration.yml @@ -26,6 +26,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/HighAvailability/configuration.yml b/internal/suites/HighAvailability/configuration.yml index e9cd1a52b..2b0cc5987 100644 --- a/internal/suites/HighAvailability/configuration.yml +++ b/internal/suites/HighAvailability/configuration.yml @@ -110,6 +110,7 @@ regulation: ban_time: 10 storage: + encryption_key: a_not_so_secure_encryption_key mysql: host: mariadb port: 3306 diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index 432efaf00..4bcd9a307 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -41,6 +41,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite3 diff --git a/internal/suites/Mariadb/configuration.yml b/internal/suites/Mariadb/configuration.yml index d2adc36d1..0c406aa1f 100644 --- a/internal/suites/Mariadb/configuration.yml +++ b/internal/suites/Mariadb/configuration.yml @@ -28,6 +28,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: + encryption_key: a_not_so_secure_encryption_key mysql: host: mariadb port: 3306 diff --git a/internal/suites/MySQL/configuration.yml b/internal/suites/MySQL/configuration.yml index d847cb2f1..f57595375 100644 --- a/internal/suites/MySQL/configuration.yml +++ b/internal/suites/MySQL/configuration.yml @@ -29,6 +29,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: + encryption_key: a_not_so_secure_encryption_key mysql: host: mysql port: 3306 diff --git a/internal/suites/NetworkACL/configuration.yml b/internal/suites/NetworkACL/configuration.yml index 1c83a54b1..a4a6103d0 100644 --- a/internal/suites/NetworkACL/configuration.yml +++ b/internal/suites/NetworkACL/configuration.yml @@ -27,6 +27,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/OIDC/configuration.yml b/internal/suites/OIDC/configuration.yml index 8508271de..c63892dc4 100644 --- a/internal/suites/OIDC/configuration.yml +++ b/internal/suites/OIDC/configuration.yml @@ -27,6 +27,7 @@ session: port: 6379 storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/OIDCTraefik/configuration.yml b/internal/suites/OIDCTraefik/configuration.yml index 3381211fe..cc949e563 100644 --- a/internal/suites/OIDCTraefik/configuration.yml +++ b/internal/suites/OIDCTraefik/configuration.yml @@ -27,6 +27,7 @@ session: port: 6379 storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/OneFactorOnly/configuration.yml b/internal/suites/OneFactorOnly/configuration.yml index b90513fec..32ead64e6 100644 --- a/internal/suites/OneFactorOnly/configuration.yml +++ b/internal/suites/OneFactorOnly/configuration.yml @@ -27,6 +27,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/PathPrefix/configuration.yml b/internal/suites/PathPrefix/configuration.yml index 72f37c065..b2865affc 100644 --- a/internal/suites/PathPrefix/configuration.yml +++ b/internal/suites/PathPrefix/configuration.yml @@ -27,6 +27,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/Postgres/configuration.yml b/internal/suites/Postgres/configuration.yml index 53ce1e700..a95ffa96c 100644 --- a/internal/suites/Postgres/configuration.yml +++ b/internal/suites/Postgres/configuration.yml @@ -28,6 +28,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: + encryption_key: a_not_so_secure_encryption_key postgres: host: postgres port: 5432 diff --git a/internal/suites/ShortTimeouts/configuration.yml b/internal/suites/ShortTimeouts/configuration.yml index a091b2c94..0ac5fe3fe 100644 --- a/internal/suites/ShortTimeouts/configuration.yml +++ b/internal/suites/ShortTimeouts/configuration.yml @@ -27,6 +27,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml index 5dec5e49e..4f5e71510 100644 --- a/internal/suites/Standalone/configuration.yml +++ b/internal/suites/Standalone/configuration.yml @@ -25,6 +25,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /tmp/db.sqlite3 diff --git a/internal/suites/Traefik/configuration.yml b/internal/suites/Traefik/configuration.yml index d76301826..31c5d75ba 100644 --- a/internal/suites/Traefik/configuration.yml +++ b/internal/suites/Traefik/configuration.yml @@ -27,6 +27,7 @@ session: remember_me_duration: 1y storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/Traefik2/configuration.yml b/internal/suites/Traefik2/configuration.yml index 6390464aa..3192c9bec 100644 --- a/internal/suites/Traefik2/configuration.yml +++ b/internal/suites/Traefik2/configuration.yml @@ -32,6 +32,7 @@ session: password: redis-user-password storage: + encryption_key: a_not_so_secure_encryption_key local: path: /config/db.sqlite diff --git a/internal/suites/example/kube/authelia/deployment.yml b/internal/suites/example/kube/authelia/deployment.yml index 6de87f878..6a4156ecf 100644 --- a/internal/suites/example/kube/authelia/deployment.yml +++ b/internal/suites/example/kube/authelia/deployment.yml @@ -40,6 +40,8 @@ spec: value: /app/secrets/session - name: AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE value: /app/secrets/sql_password + - name: AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE + value: /app/secrets/encryption_key - name: ENVIRONMENT value: dev volumes: @@ -69,4 +71,6 @@ spec: path: sql_password - key: ldap_password path: ldap_password + - key: encryption_key + path: encryption_key ... diff --git a/internal/suites/example/kube/authelia/secret.yml b/internal/suites/example/kube/authelia/secret.yml index 8524cf115..de5e501e5 100644 --- a/internal/suites/example/kube/authelia/secret.yml +++ b/internal/suites/example/kube/authelia/secret.yml @@ -12,4 +12,5 @@ data: ldap_password: cGFzc3dvcmQ= # password session: dW5zZWN1cmVfcGFzc3dvcmQ= # unsecure_password sql_password: cGFzc3dvcmQ= # password + encryption_key: YV9ub3Rfc29fc2VjdXJlX2VuY3J5cHRpb25fa2V5 ... diff --git a/internal/suites/suite_cli.go b/internal/suites/suite_cli.go index c02d81c0e..44210af6a 100644 --- a/internal/suites/suite_cli.go +++ b/internal/suites/suite_cli.go @@ -2,6 +2,7 @@ package suites import ( "fmt" + "os" "time" ) @@ -35,6 +36,9 @@ func init() { teardown := func(suitePath string) error { err := dockerEnvironment.Down() + _ = os.Remove("/tmp/db.sqlite3") + _ = os.Remove("/tmp/db.sqlite") + return err } diff --git a/internal/suites/suite_cli_test.go b/internal/suites/suite_cli_test.go index b0999b720..fae91cda7 100644 --- a/internal/suites/suite_cli_test.go +++ b/internal/suites/suite_cli_test.go @@ -1,11 +1,18 @@ package suites import ( + "context" + "fmt" "os" "regexp" "testing" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/suite" + + "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/storage" ) type CLISuite struct { @@ -40,7 +47,7 @@ func (s *CLISuite) SetupTest() { func (s *CLISuite) TestShouldPrintBuildInformation() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "build-info"}) - s.Assert().Nil(err) + s.Assert().NoError(err) s.Assert().Contains(output, "Last Tag: ") s.Assert().Contains(output, "State: ") s.Assert().Contains(output, "Branch: ") @@ -55,13 +62,13 @@ func (s *CLISuite) TestShouldPrintBuildInformation() { func (s *CLISuite) TestShouldPrintVersion() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "--version"}) - s.Assert().Nil(err) + s.Assert().NoError(err) s.Assert().Contains(output, "authelia version") } func (s *CLISuite) TestShouldValidateConfig() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "validate-config", "/config/configuration.yml"}) - s.Assert().Nil(err) + s.Assert().NoError(err) s.Assert().Contains(output, "Configuration parsed successfully without errors") } @@ -73,33 +80,33 @@ func (s *CLISuite) TestShouldFailValidateConfig() { func (s *CLISuite) TestShouldHashPasswordArgon2id() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "hash-password", "test", "-m", "32", "-s", "test1234"}) - s.Assert().Nil(err) + s.Assert().NoError(err) s.Assert().Contains(output, "Password hash: $argon2id$v=19$m=32768,t=1,p=8") } func (s *CLISuite) TestShouldHashPasswordSHA512() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "hash-password", "test", "-z"}) - s.Assert().Nil(err) + s.Assert().NoError(err) s.Assert().Contains(output, "Password hash: $6$rounds=50000") } 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().NoError(err) 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().NoError(err) 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().NoError(err) s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } @@ -112,14 +119,14 @@ func (s *CLISuite) TestShouldFailGenerateCertificateRSAWithStartDate() { 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().NoError(err) 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().NoError(err) s.Assert().Contains(output, "Certificate Public Key written to /tmp/cert.pem") s.Assert().Contains(output, "Certificate Private Key written to /tmp/key.pem") } @@ -132,32 +139,238 @@ func (s *CLISuite) TestShouldFailGenerateCertificateECDSA() { 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().NoError(err) 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().NoError(err) 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().NoError(err) 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().NoError(err) 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) TestStorageShouldShowErrWithoutConfig() { + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info"}) + s.Assert().EqualError(err, "exit status 1") + + s.Assert().Contains(output, "Error: A storage configuration must be provided. It could be 'local', 'mysql' or 'postgres', the configuration option storage.encryption_key must be provided\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "migrate", "history"}) + s.Assert().EqualError(err, "exit status 1") + + s.Assert().Contains(output, "Error: A storage configuration must be provided. It could be 'local', 'mysql' or 'postgres', the configuration option storage.encryption_key must be provided\n") +} + +func (s *CLISuite) TestStorage00ShouldShowCorrectPreInitInformation() { + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + pattern := regexp.MustCompile(`^Schema Version: N/A\nSchema Upgrade Available: yes - version \d+\nSchema Tables: N/A\nSchema Encryption Key: unsupported \(schema version\)`) + + s.Assert().Regexp(pattern, output) + + patternOutdated := regexp.MustCompile(`Error: schema is version \d+ which is outdated please migrate to version \d+ in order to use this command or use an older binary`) + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "export", "totp-configurations", "--config", "/config/configuration.storage.yml"}) + s.Assert().EqualError(err, "exit status 1") + s.Assert().Regexp(patternOutdated, output) + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "change-key", "--config", "/config/configuration.storage.yml"}) + s.Assert().EqualError(err, "exit status 1") + s.Assert().Regexp(patternOutdated, output) + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + s.Assert().Contains(output, "Could not check encryption key for validity. The schema version doesn't support encryption.") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "migrate", "down", "--target", "0", "--destroy-data", "--config", "/config/configuration.storage.yml"}) + s.Assert().EqualError(err, "exit status 1") + s.Assert().Contains(output, "Error: schema migration target version 0 is the same current version 0") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "migrate", "up", "--target", "2147483640", "--config", "/config/configuration.storage.yml"}) + s.Assert().EqualError(err, "exit status 1") + s.Assert().Contains(output, "Error: schema up migration target version 2147483640 is greater then the latest version ") + s.Assert().Contains(output, " which indicates it doesn't exist") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "history"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "No migration history is available for schemas that not version 1 or above.\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "list-up"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Storage Schema Migration List (Up)\n\nVersion\t\tDescription\n1\t\tInitial Schema\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "list-down"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Storage Schema Migration List (Down)\n\nNo Migrations Available\n") +} + +func (s *CLISuite) TestStorage01ShouldMigrateUp() { + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "up"}) + s.Require().NoError(err) + + pattern0 := regexp.MustCompile(`"Storage schema migration from \d+ to \d+ is being attempted"`) + pattern1 := regexp.MustCompile(`"Storage schema migration from \d+ to \d+ is complete"`) + + s.Regexp(pattern0, output) + s.Regexp(pattern1, output) + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "up"}) + s.Assert().EqualError(err, "exit status 1") + + s.Assert().Contains(output, "Error: schema already up to date\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "history"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Migration History:\n\nID\tDate\t\t\t\tBefore\tAfter\tAuthelia Version\n") + s.Assert().Contains(output, "0\t1") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "list-up"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Storage Schema Migration List (Up)\n\nNo Migrations Available") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "--config", "/config/configuration.storage.yml", "migrate", "list-down"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Storage Schema Migration List (Down)\n\nVersion\t\tDescription\n") + s.Assert().Contains(output, "1\t\tInitial Schema") +} + +func (s *CLISuite) TestStorage02ShouldShowSchemaInfo() { + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + pattern := regexp.MustCompile(`^Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification_tokens, totp_configurations, u2f_devices, user_preferences, migrations, encryption\nSchema Encryption Key: valid`) + + s.Assert().Regexp(pattern, output) +} + +func (s *CLISuite) TestStorage03ShouldExportTOTP() { + provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_cli_encryption_key_which_isnt_secure") + + s.Require().NoError(provider.StartupCheck()) + + ctx := context.Background() + + var ( + err error + key *otp.Key + config models.TOTPConfiguration + ) + + var ( + expectedLines = make([]string, 0, 3) + expectedLinesCSV = make([]string, 0, 4) + output string + ) + + expectedLinesCSV = append(expectedLinesCSV, "issuer,username,algorithm,digits,period,secret") + + for _, name := range []string{"john", "mary", "fred"} { + key, err = totp.Generate(totp.GenerateOpts{ + Issuer: "Authelia", + AccountName: name, + Period: uint(30), + SecretSize: 32, + Digits: otp.Digits(6), + Algorithm: otp.AlgorithmSHA1, + }) + s.Require().NoError(err) + + config = models.TOTPConfiguration{ + Username: name, + Algorithm: "SHA1", + Digits: 6, + Secret: []byte(key.Secret()), + Period: key.Period(), + } + + expectedLinesCSV = append(expectedLinesCSV, fmt.Sprintf("%s,%s,%s,%d,%d,%s", "Authelia", config.Username, config.Algorithm, config.Digits, config.Period, string(config.Secret))) + expectedLines = append(expectedLines, fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d", "Authelia", config.Username, string(config.Secret), "Authelia", config.Algorithm, config.Digits, config.Period)) + + s.Require().NoError(provider.SaveTOTPConfiguration(ctx, config)) + } + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "export", "totp-configurations", "--format", "uri", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + for _, expectedLine := range expectedLines { + s.Assert().Contains(output, expectedLine) + } + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "export", "totp-configurations", "--format", "csv", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + for _, expectedLine := range expectedLinesCSV { + s.Assert().Contains(output, expectedLine) + } +} + +func (s *CLISuite) TestStorage04ShouldChangeEncryptionKey() { + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "change-key", "--new-encryption-key", "apple-apple-apple-apple", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Completed the encryption key change. Please adjust your configuration to use the new key.\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + pattern := regexp.MustCompile(`Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification_tokens, totp_configurations, u2f_devices, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`) + s.Assert().Regexp(pattern, output) + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Encryption key validation: failed.\n\nError: the encryption key is not valid against the schema check value.\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--verbose", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Encryption key validation: failed.\n\nError: the encryption key is not valid against the schema check value, 3 of 3 total TOTP secrets were invalid.\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--encryption-key", "apple-apple-apple-apple", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Encryption key validation: success.\n") + + output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--verbose", "--encryption-key", "apple-apple-apple-apple", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + s.Assert().Contains(output, "Encryption key validation: success.\n") +} + +func (s *CLISuite) TestStorage05ShouldMigrateDown() { + output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "migrate", "down", "--target", "0", "--destroy-data", "--config", "/config/configuration.storage.yml"}) + s.Assert().NoError(err) + + pattern0 := regexp.MustCompile(`"Storage schema migration from \d+ to \d+ is being attempted"`) + pattern1 := regexp.MustCompile(`"Storage schema migration from \d+ to \d+ is complete"`) + + s.Regexp(pattern0, output) + s.Regexp(pattern1, output) +} + func TestCLISuite(t *testing.T) { if testing.Short() { t.Skip("skipping suite test in short mode") diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index f02402027..75d3ab351 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -121,7 +121,7 @@ func (s *StandaloneWebDriverSuite) TestShouldCheckUserIsAskedToRegisterDevice() password := "password" // Clean up any TOTP secret already in DB. - provider := storage.NewSQLiteProvider("/tmp/db.sqlite3") + provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key") require.NoError(s.T(), provider.StartupCheck()) require.NoError(s.T(), provider.DeleteTOTPConfiguration(ctx, username))