From f2b93c04020f399e4901499ad2b3804773027502 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Fri, 14 May 2021 19:21:15 +0200 Subject: [PATCH] add a setup screen to create the first admin user If you prefer to auto-create the first admin you can enable the "create_default_admin" configuration key and SFTPGo will work as before. You can also create the first admin by loading initial data: now you can set both username and password, before you could only change the password --- README.md | 10 ++- common/protocol_test.go | 1 + config/config.go | 4 +- dataprovider/dataprovider.go | 61 +++++++++---- docker/README.md | 2 +- docs/full-configuration.md | 3 +- docs/howto/postgresql-s3.md | 8 +- docs/virtual-folders.md | 2 +- docs/web-admin.md | 5 +- ftpd/ftpd_test.go | 1 + httpd/httpd.go | 3 + httpd/httpd_test.go | 118 ++++++++++++++++++++++++ httpd/server.go | 22 +++-- httpd/webadmin.go | 75 ++++++++++++++++ sftpd/sftpd_test.go | 1 + sftpgo.json | 3 +- templates/webadmin/adminsetup.html | 138 +++++++++++++++++++++++++++++ templates/webadmin/login.html | 4 +- templates/webclient/login.html | 4 +- webdavd/webdavd_test.go | 1 + 20 files changed, 430 insertions(+), 36 deletions(-) create mode 100644 templates/webadmin/adminsetup.html diff --git a/README.md b/README.md index 6c0913e4..48d852e7 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,14 @@ sftpgo initprovider --help You can disable automatic data provider checks/updates at startup by setting the `update_mode` configuration key to `1`. +## Create the first admin + +To start using SFTPGo you need to create an admin user, you can do it in several ways: + +- by using the web admin interface. The default URL is [http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin) +- by loading initial data +- by enabling `create_default_admin` in your configuration file. In this case the credentials are `admin`/`password` + ## Upgrading SFTPGo supports upgrading from the previous release branch to the current one. @@ -134,7 +142,7 @@ For supported upgrade paths, the data and schema are migrated automatically, alt So if, for example, you want to upgrade from a version before 1.2.x to 2.0.x, you must first install version 1.2.x, update the data provider and finally install the version 2.0.x. It is recommended to always install the latest available minor version, ie do not install 1.2.0 if 1.2.2 is available. -Loading data from a provider independent JSON dump is supported from the previous release branch to the current one too. After updating SFTPGo it is advisable to load the old dump and regenerate it from the new version. +Loading data from a provider independent JSON dump is supported from the previous release branch to the current one too. After upgrading SFTPGo it is advisable to regenerate the JSON dump from the new version. ## Downgrading diff --git a/common/protocol_test.go b/common/protocol_test.go index 1dc74f19..9b5d50c9 100644 --- a/common/protocol_test.go +++ b/common/protocol_test.go @@ -60,6 +60,7 @@ func TestMain(m *testing.M) { logFilePath := filepath.Join(configDir, "common_test.log") logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel) + os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1") err := config.LoadConfig(configDir, "") if err != nil { logger.ErrorToConsole("error loading configuration: %v", err) diff --git a/config/config.go b/config/config.go index 52da98d8..2caef2ec 100644 --- a/config/config.go +++ b/config/config.go @@ -235,6 +235,7 @@ func Init() { PreferDatabaseCredentials: false, SkipNaturalKeysValidation: false, DelayedQuotaUpdate: 0, + CreateDefaultAdmin: false, }, HTTPDConfig: httpd.Conf{ Bindings: []httpd.Binding{defaultHTTPDBinding}, @@ -979,10 +980,11 @@ func setViperDefaults() { viper.SetDefault("data_provider.password_hashing.argon2_options.iterations", globalConf.ProviderConf.PasswordHashing.Argon2Options.Iterations) viper.SetDefault("data_provider.password_hashing.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism) viper.SetDefault("data_provider.password_hashing.algo", globalConf.ProviderConf.PasswordHashing.Algo) + viper.SetDefault("data_provider.password_caching", globalConf.ProviderConf.PasswordCaching) viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode) viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation) viper.SetDefault("data_provider.delayed_quota_update", globalConf.ProviderConf.DelayedQuotaUpdate) - viper.SetDefault("data_provider.password_caching", globalConf.ProviderConf.PasswordCaching) + viper.SetDefault("data_provider.create_default_admin", globalConf.ProviderConf.CreateDefaultAdmin) viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath) viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath) viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath) diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 3ea69681..d1be615c 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -29,6 +29,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/GehirnInc/crypt" @@ -118,6 +119,7 @@ var ( ErrNoInitRequired = errors.New("the data provider is up to date") // ErrInvalidCredentials defines the error to return if the supplied credentials are invalid ErrInvalidCredentials = errors.New("invalid credentials") + isAdminCreated = int32(0) validTLSUsernames = []string{string(TLSUsernameNone), string(TLSUsernameCN)} config Config provider Provider @@ -306,6 +308,10 @@ type Config struct { // failures, file copied outside of SFTPGo, and so on. // 0 means immediate quota update. DelayedQuotaUpdate int `json:"delayed_quota_update" mapstructure:"delayed_quota_update"` + // If enabled, a default admin user with username "admin" and password "password" will be created + // on first start. + // You can also create the first admin user by using the web interface or by loading initial data. + CreateDefaultAdmin bool `json:"create_default_admin" mapstructure:"create_default_admin"` } // BackupData defines the structure for the backup/restore files @@ -455,21 +461,9 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error { credentialsDirPath = filepath.Join(basePath, config.CredentialsPath) } vfs.SetCredentialsDirPath(credentialsDirPath) - argon2Params = &argon2id.Params{ - Memory: cnf.PasswordHashing.Argon2Options.Memory, - Iterations: cnf.PasswordHashing.Argon2Options.Iterations, - Parallelism: cnf.PasswordHashing.Argon2Options.Parallelism, - SaltLength: 16, - KeyLength: 32, - } - if config.PasswordHashing.Algo == HashingAlgoBcrypt { - if config.PasswordHashing.BcryptOptions.Cost > bcrypt.MaxCost { - err = fmt.Errorf("invalid bcrypt cost %v, max allowed %v", config.PasswordHashing.BcryptOptions.Cost, bcrypt.MaxCost) - logger.WarnToConsole("Unable to initialize data provider: %v", err) - providerLog(logger.LevelWarn, "Unable to initialize data provider: %v", err) - return err - } + if err = initializeHashingAlgo(&cnf); err != nil { + return err } if err = validateHooks(); err != nil { @@ -494,7 +488,7 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error { providerLog(logger.LevelWarn, "database migration error: %v", err) return err } - if checkAdmins { + if checkAdmins && cnf.CreateDefaultAdmin { err = checkDefaultAdmin() if err != nil { providerLog(logger.LevelWarn, "check default admin error: %v", err) @@ -504,6 +498,11 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error { } else { providerLog(logger.LevelInfo, "database initialization/migration skipped, manual mode is configured") } + admins, err := provider.getAdmins(1, 0, OrderASC) + if err != nil { + return err + } + atomic.StoreInt32(&isAdminCreated, int32(len(admins))) startAvailabilityTimer() delayedQuotaUpdater.start() return nil @@ -538,6 +537,26 @@ func validateHooks() error { return nil } +func initializeHashingAlgo(cnf *Config) error { + argon2Params = &argon2id.Params{ + Memory: cnf.PasswordHashing.Argon2Options.Memory, + Iterations: cnf.PasswordHashing.Argon2Options.Iterations, + Parallelism: cnf.PasswordHashing.Argon2Options.Parallelism, + SaltLength: 16, + KeyLength: 32, + } + + if config.PasswordHashing.Algo == HashingAlgoBcrypt { + if config.PasswordHashing.BcryptOptions.Cost > bcrypt.MaxCost { + err := fmt.Errorf("invalid bcrypt cost %v, max allowed %v", config.PasswordHashing.BcryptOptions.Cost, bcrypt.MaxCost) + logger.WarnToConsole("Unable to initialize data provider: %v", err) + providerLog(logger.LevelWarn, "Unable to initialize data provider: %v", err) + return err + } + } + return nil +} + func validateSQLTablesPrefix() error { if config.SQLTablesPrefix != "" { for _, char := range config.SQLTablesPrefix { @@ -859,9 +878,19 @@ func GetUsedVirtualFolderQuota(name string) (int, int64, error) { return files + delayedFiles, size + delayedSize, err } +// HasAdmin returns true if the first admin has been created +// and so SFTPGo is ready to be used +func HasAdmin() bool { + return atomic.LoadInt32(&isAdminCreated) > 0 +} + // AddAdmin adds a new SFTPGo admin func AddAdmin(admin *Admin) error { - return provider.addAdmin(admin) + err := provider.addAdmin(admin) + if err == nil { + atomic.StoreInt32(&isAdminCreated, 1) + } + return err } // UpdateAdmin updates an existing SFTPGo admin diff --git a/docker/README.md b/docker/README.md index 9b427ec9..4749a6b8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -25,7 +25,7 @@ docker run --name some-sftpgo -p 127.0.0.1:8080:8080 -p 2022:2022 -d "drakkan/sf ... where `some-sftpgo` is the name you want to assign to your container, and `tag` is the tag specifying the SFTPGo version you want. See the list above for relevant tags. -Now visit [http://localhost:8080/](http://localhost:8080/), the default credentials are `admin/password`, and create a new SFTPGo user. The SFTP service is available on port 2022. +Now visit [http://localhost:8080/web/admin](http://localhost:8080/web/admin), create the first admin and then log in and create a new SFTPGo user. The SFTP service is available on port 2022. If you don't want to persist any files, for example for testing purposes, you can run an SFTPGo instance like this: diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 2461458e..21a94361 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -181,6 +181,7 @@ The configuration file contains the following sections: - 0, disable quota tracking. REST API to scan users home directories/virtual folders and update quota will do nothing - 1, quota is updated each time a user uploads or deletes a file, even if the user has no quota restrictions - 2, quota is updated each time a user uploads or deletes a file, but only for users with quota restrictions and for virtual folders. With this configuration, the `quota scan` and `folder_quota_scan` REST API can still be used to periodically update space usage for users without quota restrictions and for folders + - `delayed_quota_update`, integer. This configuration parameter defines the number of seconds to accumulate quota updates. If there are a lot of close uploads, accumulating quota updates can save you many queries to the data provider. If you want to track quotas, a scheduled quota update is recommended in any case, the stored quota may be incorrect for several reasons, such as an unexpected shutdown while uploading files, temporary provider failures, files copied outside of SFTPGo, and so on. You could use the [quotascan example](../examples/quotascan) as a starting point. 0 means immediate quota update. - `pool_size`, integer. Sets the maximum number of open connections for `mysql` and `postgresql` driver. Default 0 (unlimited) - `users_base_dir`, string. Users default base directory. If no home dir is defined while adding a new user, and this value is a valid absolute path, then the user home dir will be automatically defined as the path obtained joining the base dir and the username - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See [Custom Actions](./custom-actions.md) for more details @@ -208,7 +209,7 @@ The configuration file contains the following sections: - `password_caching`, boolean. Verifying argon2id passwords has a high memory and computational cost, verifying bcrypt passwords has a high computational cost, by enabling, in memory, password caching you reduce these costs. Default: `true` - `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command. - `skip_natural_keys_validation`, boolean. If `true` you can use any UTF-8 character for natural keys as username, admin name, folder name. These keys are used in URIs for REST API and Web admin. If `false` only unreserved URI characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". Default: `false`. - - `delayed_quota_update`, integer. This configuration parameter defines the number of seconds to accumulate quota updates. If there are a lot of close uploads, accumulating quota updates can save you many queries to the data provider. If you want to track quotas, a scheduled quota update is recommended in any case, the stored quota may be incorrect for several reasons, such as an unexpected shutdown while uploading files, temporary provider failures, files copied outside of SFTPGo, and so on. You could use the [quotascan example](../examples/quotascan) as a starting point. 0 means immediate quota update. + - `create_default_admin`, boolean. If enabled, a default admin user with username `admin` and password `password` will be created on first start. You can also create the first admin user by using the web interface or by loading initial data. Default `false`. - **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface - `bindings`, list of structs. Each struct has the following fields: - `port`, integer. The port used for serving HTTP requests. Default: 8080. diff --git a/docs/howto/postgresql-s3.md b/docs/howto/postgresql-s3.md index 468ba19d..168d3b62 100644 --- a/docs/howto/postgresql-s3.md +++ b/docs/howto/postgresql-s3.md @@ -188,11 +188,17 @@ sudo systemctl restart sftpgo systemctl status sftpgo ``` +## Create the first admin + +To start using SFTPGo you need to create an admin user, the easiest way is to use the built-in Web admin interface, so open the Web Admin URL and create the first admin user. + +[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin) + ## Add virtual users The easiest way to add virtual users is to use the built-in Web interface. -So navigate to the Web Admin URL. +So navigate to the Web Admin URL again and log in using the credentials you just set up. [http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin) diff --git a/docs/virtual-folders.md b/docs/virtual-folders.md index 2b76def5..a8b9477d 100644 --- a/docs/virtual-folders.md +++ b/docs/virtual-folders.md @@ -16,7 +16,7 @@ For each virtual folder, the following properties can be configured: - `quota_size`, maximum size allowed as bytes. 0 means unlimited, -1 included in user quota - `quota_files`, maximum number of files allowed. 0 means unlimited, -1 included in user quota -For example if the configure folder has configured `/tmp/mapped` or `C:\mapped` as filesystem path and you set `/vfolder` as virtual path then SFTPGo users can access `/tmp/mapped` or `C:\mapped` via the `/vfolder` virtual path. +For example if a folder is configured to use `/tmp/mapped` or `C:\mapped` as filesystem path and `/vfolder` as virtual path then SFTPGo users can access `/tmp/mapped` or `C:\mapped` via the `/vfolder` virtual path. Nested SFTP folders using the same SFTPGo instance (identified using the host keys) are not allowed as they could cause infinite SFTP loops. diff --git a/docs/web-admin.md b/docs/web-admin.md index 44929a88..8ceab140 100644 --- a/docs/web-admin.md +++ b/docs/web-admin.md @@ -5,9 +5,6 @@ With the default `httpd` configuration, the web admin is available at the follow [http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin) -The default credentials are: - -- username: `admin` -- password: `password` +If no admin user is found within the data provider, typically after the initial installation, SFTPGo will ask you to create the first admin. You can also pre-create an admin user by loading initial data or by enabling the `create_default_admin` configuration key. Please take a look [here](./full-configuration.md) for more details. The web interface can be exposed via HTTPS and may require mutual TLS authentication in addition to administrator credentials. diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index b10c8025..ac78a059 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -249,6 +249,7 @@ func TestMain(m *testing.M) { // simply does not execute some code so if it works in atomic mode will // work in non atomic mode too os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2") + os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1") err = config.LoadConfig(configDir, "") if err != nil { logger.ErrorToConsole("error loading configuration: %v", err) diff --git a/httpd/httpd.go b/httpd/httpd.go index 71f72086..84ab9a31 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -54,6 +54,7 @@ const ( webBasePathDefault = "/web" webBasePathAdminDefault = "/web/admin" webBasePathClientDefault = "/web/client" + webAdminSetupPathDefault = "/web/admin/setup" webLoginPathDefault = "/web/admin/login" webLogoutPathDefault = "/web/admin/logout" webUsersPathDefault = "/web/admin/users" @@ -97,6 +98,7 @@ var ( webBasePath string webBaseAdminPath string webBaseClientPath string + webAdminSetupPath string webLoginPath string webLogoutPath string webUsersPath string @@ -424,6 +426,7 @@ func updateWebAdminURLs(baseURL string) { webRootPath = path.Join(baseURL, webRootPathDefault) webBasePath = path.Join(baseURL, webBasePathDefault) webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault) + webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault) webLoginPath = path.Join(baseURL, webLoginPathDefault) webLogoutPath = path.Join(baseURL, webLogoutPathDefault) webUsersPath = path.Join(baseURL, webUsersPathDefault) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 84ba0309..02430932 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -71,6 +71,7 @@ const ( healthzPath = "/healthz" webBasePath = "/web" webBasePathAdmin = "/web/admin" + webAdminSetupPath = "/web/admin/setup" webLoginPath = "/web/admin/login" webLogoutPath = "/web/admin/logout" webUsersPath = "/web/admin/users" @@ -168,6 +169,7 @@ func TestMain(m *testing.M) { logfilePath := filepath.Join(configDir, "sftpgo_api_test.log") logger.InitLogger(logfilePath, 5, 1, 28, false, zerolog.DebugLevel) os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2") + os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1") err := config.LoadConfig(configDir, "") if err != nil { logger.WarnToConsole("error loading configuration: %v", err) @@ -5144,6 +5146,122 @@ func TestClientUserClose(t *testing.T) { assert.NoError(t, err) } +func TestWebAdminSetupMock(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, webAdminSetupPath, nil) + assert.NoError(t, err) + rr := executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + // now delete all the admins + admins, err := dataprovider.GetAdmins(100, 0, dataprovider.OrderASC) + assert.NoError(t, err) + for _, admin := range admins { + err = dataprovider.DeleteAdmin(admin.Username) + assert.NoError(t, err) + } + // close the provider and initializes it without creating the default admin + os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "0") + err = dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + providerConf.CredentialsPath = credentialsPath + err = os.RemoveAll(credentialsPath) + assert.NoError(t, err) + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + // now the setup page must be rendered + req, err = http.NewRequest(http.MethodGet, webAdminSetupPath, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // check redirects to the setup page + req, err = http.NewRequest(http.MethodGet, "/", nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location")) + req, err = http.NewRequest(http.MethodGet, webBasePath, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location")) + req, err = http.NewRequest(http.MethodGet, webBasePathAdmin, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location")) + + csrfToken, err := getCSRFToken(httpBaseURL + webAdminSetupPath) + assert.NoError(t, err) + form := make(url.Values) + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Please set a username") + form.Set("username", defaultTokenAuthUser) + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Please set a password") + form.Set("password", defaultTokenAuthPass) + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Passwords mismatch") + form.Set("confirm_password", defaultTokenAuthPass) + // test a parse form error + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath+"?param=p%C3%AO%GH", bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // test a dataprovider error + err = dataprovider.Close() + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // finally initialize the provider and create the default admin + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf = config.GetProviderConf() + providerConf.CredentialsPath = credentialsPath + err = os.RemoveAll(credentialsPath) + assert.NoError(t, err) + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + // if we resubmit the form we get a bad request, an admin already exists + req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "an admin user already exists") + os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1") +} + func TestWebAdminLoginMock(t *testing.T) { webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) diff --git a/httpd/server.go b/httpd/server.go index 7579c64a..25b3fa07 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -396,6 +396,16 @@ func (s *httpdServer) sendForbiddenResponse(w http.ResponseWriter, r *http.Reque sendAPIResponse(w, r, errors.New(message), message, http.StatusForbidden) } +func (s *httpdServer) redirectToWebPath(w http.ResponseWriter, r *http.Request, webPath string) { + if dataprovider.HasAdmin() { + http.Redirect(w, r, webPath, http.StatusMovedPermanently) + return + } + if s.enableWebAdmin { + http.Redirect(w, r, webAdminSetupPath, http.StatusFound) + } +} + func (s *httpdServer) initializeRouter() { s.tokenAuth = jwtauth.New(jwa.HS256.String(), utils.GenerateRandomBytes(32), nil) s.router = chi.NewRouter() @@ -485,17 +495,17 @@ func (s *httpdServer) initializeRouter() { }) if s.enableWebClient { s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently) + s.redirectToWebPath(w, r, webClientLoginPath) }) s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently) + s.redirectToWebPath(w, r, webClientLoginPath) }) } else { s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + s.redirectToWebPath(w, r, webLoginPath) }) s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + s.redirectToWebPath(w, r, webLoginPath) }) } } @@ -522,10 +532,12 @@ func (s *httpdServer) initializeRouter() { if s.enableWebAdmin { s.router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + s.redirectToWebPath(w, r, webLoginPath) }) s.router.Get(webLoginPath, handleWebLogin) s.router.Post(webLoginPath, s.handleWebAdminLoginPost) + s.router.Get(webAdminSetupPath, handleWebAdminSetupGet) + s.router.Post(webAdminSetupPath, handleWebAdminSetupPost) s.router.Group(func(router chi.Router) { router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie)) diff --git a/httpd/webadmin.go b/httpd/webadmin.go index a96005b4..9fbe5533 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -54,6 +54,7 @@ const ( templateLogin = "login.html" templateChangePwd = "changepwd.html" templateMaintenance = "maintenance.html" + templateSetup = "adminsetup.html" pageUsersTitle = "Users" pageAdminsTitle = "Admins" pageConnectionsTitle = "Connections" @@ -61,6 +62,7 @@ const ( pageFoldersTitle = "Folders" pageChangePwdTitle = "Change password" pageMaintenanceTitle = "Maintenance" + pageSetupTitle = "Create first admin user" defaultQueryLimit = 500 ) @@ -156,6 +158,12 @@ type maintenancePage struct { Error string } +type setupPage struct { + basePage + Username string + Error string +} + type folderPage struct { basePage Folder vfs.BaseVirtualFolder @@ -225,6 +233,9 @@ func loadAdminTemplates(templatesPath string) { filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateMaintenance), } + setupPath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateSetup), + } usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...)) userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...)) adminsTmpl := utils.LoadTemplate(template.ParseFiles(adminsPaths...)) @@ -237,6 +248,7 @@ func loadAdminTemplates(templatesPath string) { loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...)) changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...)) maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...)) + setupTmpl := utils.LoadTemplate(template.ParseFiles(setupPath...)) adminTemplates[templateUsers] = usersTmpl adminTemplates[templateUser] = userTmpl @@ -250,6 +262,7 @@ func loadAdminTemplates(templatesPath string) { adminTemplates[templateLogin] = loginTmpl adminTemplates[templateChangePwd] = changePwdTmpl adminTemplates[templateMaintenance] = maintenanceTmpl + adminTemplates[templateSetup] = setupTmpl } func getBasePageData(title, currentURL string, r *http.Request) basePage { @@ -348,6 +361,16 @@ func renderMaintenancePage(w http.ResponseWriter, r *http.Request, error string) renderAdminTemplate(w, templateMaintenance, data) } +func renderAdminSetupPage(w http.ResponseWriter, r *http.Request, username, error string) { + data := setupPage{ + basePage: getBasePageData(pageSetupTitle, webAdminSetupPath, r), + Username: username, + Error: error, + } + + renderAdminTemplate(w, templateSetup, data) +} + func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin, error string, isAdd bool) { currentURL := webAdminPath @@ -1094,6 +1117,58 @@ func handleGetWebAdmins(w http.ResponseWriter, r *http.Request) { renderAdminTemplate(w, templateAdmins, data) } +func handleWebAdminSetupGet(w http.ResponseWriter, r *http.Request) { + if dataprovider.HasAdmin() { + http.Redirect(w, r, webLoginPath, http.StatusFound) + return + } + renderAdminSetupPage(w, r, "", "") +} + +func handleWebAdminSetupPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize) + if dataprovider.HasAdmin() { + renderBadRequestPage(w, r, errors.New("an admin user already exists")) + return + } + err := r.ParseForm() + if err != nil { + renderAdminSetupPage(w, r, "", err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + username := r.Form.Get("username") + password := r.Form.Get("password") + confirmPassword := r.Form.Get("confirm_password") + if username == "" { + renderAdminSetupPage(w, r, username, "Please set a username") + return + } + if password == "" { + renderAdminSetupPage(w, r, username, "Please set a password") + return + } + if password != confirmPassword { + renderAdminSetupPage(w, r, username, "Passwords mismatch") + return + } + admin := dataprovider.Admin{ + Username: username, + Password: password, + Status: 1, + Permissions: []string{dataprovider.PermAdminAny}, + } + err = dataprovider.AddAdmin(&admin) + if err != nil { + renderAdminSetupPage(w, r, username, err.Error()) + return + } + http.Redirect(w, r, webLoginPath, http.StatusSeeOther) +} + func handleWebAddAdminGet(w http.ResponseWriter, r *http.Request) { admin := &dataprovider.Admin{Status: 1} renderAddUpdateAdminPage(w, r, admin, "", true) diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 3591ccb3..e6aa655a 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -147,6 +147,7 @@ func TestMain(m *testing.M) { logger.ErrorToConsole("error creating login banner: %v", err) } os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2") + os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1") err = config.LoadConfig(configDir, "") if err != nil { logger.ErrorToConsole("error loading configuration: %v", err) diff --git a/sftpgo.json b/sftpgo.json index 76412db2..b9c7cc1f 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -176,7 +176,8 @@ }, "password_caching": true, "update_mode": 0, - "skip_natural_keys_validation": false + "skip_natural_keys_validation": false, + "create_default_admin": false }, "httpd": { "bindings": [ diff --git a/templates/webadmin/adminsetup.html b/templates/webadmin/adminsetup.html new file mode 100644 index 00000000..393534da --- /dev/null +++ b/templates/webadmin/adminsetup.html @@ -0,0 +1,138 @@ + + + + + + + + + + + + SFTPGo - Setup + + + + + + + + + + + +
+ + +
+ +
+ +
+
+ +
+
+
+
+

To start using SFTPGo you need to create an admin user

+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/webadmin/login.html b/templates/webadmin/login.html index e7a35a2b..c10db000 100644 --- a/templates/webadmin/login.html +++ b/templates/webadmin/login.html @@ -99,11 +99,11 @@ class="user-custom">
+ id="inputUsername" name="username" placeholder="Username" required>
+ id="inputPassword" name="password" placeholder="Password" required>