mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-22 07:30:25 +00:00
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
This commit is contained in:
parent
0540b8780e
commit
f2b93c0402
20 changed files with 430 additions and 36 deletions
10
README.md
10
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`.
|
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
|
## Upgrading
|
||||||
|
|
||||||
SFTPGo supports upgrading from the previous release branch to the current one.
|
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.
|
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
|
## Downgrading
|
||||||
|
|
||||||
|
|
|
@ -60,6 +60,7 @@ func TestMain(m *testing.M) {
|
||||||
logFilePath := filepath.Join(configDir, "common_test.log")
|
logFilePath := filepath.Join(configDir, "common_test.log")
|
||||||
logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel)
|
logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel)
|
||||||
|
|
||||||
|
os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1")
|
||||||
err := config.LoadConfig(configDir, "")
|
err := config.LoadConfig(configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorToConsole("error loading configuration: %v", err)
|
logger.ErrorToConsole("error loading configuration: %v", err)
|
||||||
|
|
|
@ -235,6 +235,7 @@ func Init() {
|
||||||
PreferDatabaseCredentials: false,
|
PreferDatabaseCredentials: false,
|
||||||
SkipNaturalKeysValidation: false,
|
SkipNaturalKeysValidation: false,
|
||||||
DelayedQuotaUpdate: 0,
|
DelayedQuotaUpdate: 0,
|
||||||
|
CreateDefaultAdmin: false,
|
||||||
},
|
},
|
||||||
HTTPDConfig: httpd.Conf{
|
HTTPDConfig: httpd.Conf{
|
||||||
Bindings: []httpd.Binding{defaultHTTPDBinding},
|
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.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.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism)
|
||||||
viper.SetDefault("data_provider.password_hashing.algo", globalConf.ProviderConf.PasswordHashing.Algo)
|
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.update_mode", globalConf.ProviderConf.UpdateMode)
|
||||||
viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation)
|
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.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.templates_path", globalConf.HTTPDConfig.TemplatesPath)
|
||||||
viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath)
|
viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath)
|
||||||
viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath)
|
viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath)
|
||||||
|
|
|
@ -29,6 +29,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GehirnInc/crypt"
|
"github.com/GehirnInc/crypt"
|
||||||
|
@ -118,6 +119,7 @@ var (
|
||||||
ErrNoInitRequired = errors.New("the data provider is up to date")
|
ErrNoInitRequired = errors.New("the data provider is up to date")
|
||||||
// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
|
// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
|
||||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
isAdminCreated = int32(0)
|
||||||
validTLSUsernames = []string{string(TLSUsernameNone), string(TLSUsernameCN)}
|
validTLSUsernames = []string{string(TLSUsernameNone), string(TLSUsernameCN)}
|
||||||
config Config
|
config Config
|
||||||
provider Provider
|
provider Provider
|
||||||
|
@ -306,6 +308,10 @@ type Config struct {
|
||||||
// failures, file copied outside of SFTPGo, and so on.
|
// failures, file copied outside of SFTPGo, and so on.
|
||||||
// 0 means immediate quota update.
|
// 0 means immediate quota update.
|
||||||
DelayedQuotaUpdate int `json:"delayed_quota_update" mapstructure:"delayed_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
|
// BackupData defines the structure for the backup/restore files
|
||||||
|
@ -455,22 +461,10 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error {
|
||||||
credentialsDirPath = filepath.Join(basePath, config.CredentialsPath)
|
credentialsDirPath = filepath.Join(basePath, config.CredentialsPath)
|
||||||
}
|
}
|
||||||
vfs.SetCredentialsDirPath(credentialsDirPath)
|
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 err = initializeHashingAlgo(&cnf); err != nil {
|
||||||
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 err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err = validateHooks(); err != nil {
|
if err = validateHooks(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -494,7 +488,7 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error {
|
||||||
providerLog(logger.LevelWarn, "database migration error: %v", err)
|
providerLog(logger.LevelWarn, "database migration error: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if checkAdmins {
|
if checkAdmins && cnf.CreateDefaultAdmin {
|
||||||
err = checkDefaultAdmin()
|
err = checkDefaultAdmin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
providerLog(logger.LevelWarn, "check default admin error: %v", err)
|
providerLog(logger.LevelWarn, "check default admin error: %v", err)
|
||||||
|
@ -504,6 +498,11 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error {
|
||||||
} else {
|
} else {
|
||||||
providerLog(logger.LevelInfo, "database initialization/migration skipped, manual mode is configured")
|
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()
|
startAvailabilityTimer()
|
||||||
delayedQuotaUpdater.start()
|
delayedQuotaUpdater.start()
|
||||||
return nil
|
return nil
|
||||||
|
@ -538,6 +537,26 @@ func validateHooks() error {
|
||||||
return nil
|
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 {
|
func validateSQLTablesPrefix() error {
|
||||||
if config.SQLTablesPrefix != "" {
|
if config.SQLTablesPrefix != "" {
|
||||||
for _, char := range config.SQLTablesPrefix {
|
for _, char := range config.SQLTablesPrefix {
|
||||||
|
@ -859,9 +878,19 @@ func GetUsedVirtualFolderQuota(name string) (int, int64, error) {
|
||||||
return files + delayedFiles, size + delayedSize, err
|
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
|
// AddAdmin adds a new SFTPGo admin
|
||||||
func AddAdmin(admin *Admin) error {
|
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
|
// UpdateAdmin updates an existing SFTPGo admin
|
||||||
|
|
|
@ -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.
|
... 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:
|
If you don't want to persist any files, for example for testing purposes, you can run an SFTPGo instance like this:
|
||||||
|
|
||||||
|
|
|
@ -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
|
- 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
|
- 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
|
- 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)
|
- `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
|
- `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
|
- `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`
|
- `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.
|
- `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`.
|
- `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
|
- **"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:
|
- `bindings`, list of structs. Each struct has the following fields:
|
||||||
- `port`, integer. The port used for serving HTTP requests. Default: 8080.
|
- `port`, integer. The port used for serving HTTP requests. Default: 8080.
|
||||||
|
|
|
@ -188,11 +188,17 @@ sudo systemctl restart sftpgo
|
||||||
systemctl status 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
|
## Add virtual users
|
||||||
|
|
||||||
The easiest way to add virtual users is to use the built-in Web interface.
|
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)
|
[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin)
|
||||||
|
|
||||||
|
|
|
@ -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_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
|
- `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.
|
Nested SFTP folders using the same SFTPGo instance (identified using the host keys) are not allowed as they could cause infinite SFTP loops.
|
||||||
|
|
||||||
|
|
|
@ -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)
|
[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin)
|
||||||
|
|
||||||
The default credentials are:
|
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.
|
||||||
|
|
||||||
- username: `admin`
|
|
||||||
- password: `password`
|
|
||||||
|
|
||||||
The web interface can be exposed via HTTPS and may require mutual TLS authentication in addition to administrator credentials.
|
The web interface can be exposed via HTTPS and may require mutual TLS authentication in addition to administrator credentials.
|
||||||
|
|
|
@ -249,6 +249,7 @@ func TestMain(m *testing.M) {
|
||||||
// simply does not execute some code so if it works in atomic mode will
|
// simply does not execute some code so if it works in atomic mode will
|
||||||
// work in non atomic mode too
|
// work in non atomic mode too
|
||||||
os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2")
|
os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2")
|
||||||
|
os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1")
|
||||||
err = config.LoadConfig(configDir, "")
|
err = config.LoadConfig(configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorToConsole("error loading configuration: %v", err)
|
logger.ErrorToConsole("error loading configuration: %v", err)
|
||||||
|
|
|
@ -54,6 +54,7 @@ const (
|
||||||
webBasePathDefault = "/web"
|
webBasePathDefault = "/web"
|
||||||
webBasePathAdminDefault = "/web/admin"
|
webBasePathAdminDefault = "/web/admin"
|
||||||
webBasePathClientDefault = "/web/client"
|
webBasePathClientDefault = "/web/client"
|
||||||
|
webAdminSetupPathDefault = "/web/admin/setup"
|
||||||
webLoginPathDefault = "/web/admin/login"
|
webLoginPathDefault = "/web/admin/login"
|
||||||
webLogoutPathDefault = "/web/admin/logout"
|
webLogoutPathDefault = "/web/admin/logout"
|
||||||
webUsersPathDefault = "/web/admin/users"
|
webUsersPathDefault = "/web/admin/users"
|
||||||
|
@ -97,6 +98,7 @@ var (
|
||||||
webBasePath string
|
webBasePath string
|
||||||
webBaseAdminPath string
|
webBaseAdminPath string
|
||||||
webBaseClientPath string
|
webBaseClientPath string
|
||||||
|
webAdminSetupPath string
|
||||||
webLoginPath string
|
webLoginPath string
|
||||||
webLogoutPath string
|
webLogoutPath string
|
||||||
webUsersPath string
|
webUsersPath string
|
||||||
|
@ -424,6 +426,7 @@ func updateWebAdminURLs(baseURL string) {
|
||||||
webRootPath = path.Join(baseURL, webRootPathDefault)
|
webRootPath = path.Join(baseURL, webRootPathDefault)
|
||||||
webBasePath = path.Join(baseURL, webBasePathDefault)
|
webBasePath = path.Join(baseURL, webBasePathDefault)
|
||||||
webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
|
webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
|
||||||
|
webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault)
|
||||||
webLoginPath = path.Join(baseURL, webLoginPathDefault)
|
webLoginPath = path.Join(baseURL, webLoginPathDefault)
|
||||||
webLogoutPath = path.Join(baseURL, webLogoutPathDefault)
|
webLogoutPath = path.Join(baseURL, webLogoutPathDefault)
|
||||||
webUsersPath = path.Join(baseURL, webUsersPathDefault)
|
webUsersPath = path.Join(baseURL, webUsersPathDefault)
|
||||||
|
|
|
@ -71,6 +71,7 @@ const (
|
||||||
healthzPath = "/healthz"
|
healthzPath = "/healthz"
|
||||||
webBasePath = "/web"
|
webBasePath = "/web"
|
||||||
webBasePathAdmin = "/web/admin"
|
webBasePathAdmin = "/web/admin"
|
||||||
|
webAdminSetupPath = "/web/admin/setup"
|
||||||
webLoginPath = "/web/admin/login"
|
webLoginPath = "/web/admin/login"
|
||||||
webLogoutPath = "/web/admin/logout"
|
webLogoutPath = "/web/admin/logout"
|
||||||
webUsersPath = "/web/admin/users"
|
webUsersPath = "/web/admin/users"
|
||||||
|
@ -168,6 +169,7 @@ func TestMain(m *testing.M) {
|
||||||
logfilePath := filepath.Join(configDir, "sftpgo_api_test.log")
|
logfilePath := filepath.Join(configDir, "sftpgo_api_test.log")
|
||||||
logger.InitLogger(logfilePath, 5, 1, 28, false, zerolog.DebugLevel)
|
logger.InitLogger(logfilePath, 5, 1, 28, false, zerolog.DebugLevel)
|
||||||
os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2")
|
os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2")
|
||||||
|
os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1")
|
||||||
err := config.LoadConfig(configDir, "")
|
err := config.LoadConfig(configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnToConsole("error loading configuration: %v", err)
|
logger.WarnToConsole("error loading configuration: %v", err)
|
||||||
|
@ -5144,6 +5146,122 @@ func TestClientUserClose(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
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) {
|
func TestWebAdminLoginMock(t *testing.T) {
|
||||||
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
|
@ -396,6 +396,16 @@ func (s *httpdServer) sendForbiddenResponse(w http.ResponseWriter, r *http.Reque
|
||||||
sendAPIResponse(w, r, errors.New(message), message, http.StatusForbidden)
|
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() {
|
func (s *httpdServer) initializeRouter() {
|
||||||
s.tokenAuth = jwtauth.New(jwa.HS256.String(), utils.GenerateRandomBytes(32), nil)
|
s.tokenAuth = jwtauth.New(jwa.HS256.String(), utils.GenerateRandomBytes(32), nil)
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
@ -485,17 +495,17 @@ func (s *httpdServer) initializeRouter() {
|
||||||
})
|
})
|
||||||
if s.enableWebClient {
|
if s.enableWebClient {
|
||||||
s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
|
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) {
|
s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently)
|
s.redirectToWebPath(w, r, webClientLoginPath)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
|
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) {
|
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 {
|
if s.enableWebAdmin {
|
||||||
s.router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) {
|
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.Get(webLoginPath, handleWebLogin)
|
||||||
s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
|
s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
|
||||||
|
s.router.Get(webAdminSetupPath, handleWebAdminSetupGet)
|
||||||
|
s.router.Post(webAdminSetupPath, handleWebAdminSetupPost)
|
||||||
|
|
||||||
s.router.Group(func(router chi.Router) {
|
s.router.Group(func(router chi.Router) {
|
||||||
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
|
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
|
||||||
|
|
|
@ -54,6 +54,7 @@ const (
|
||||||
templateLogin = "login.html"
|
templateLogin = "login.html"
|
||||||
templateChangePwd = "changepwd.html"
|
templateChangePwd = "changepwd.html"
|
||||||
templateMaintenance = "maintenance.html"
|
templateMaintenance = "maintenance.html"
|
||||||
|
templateSetup = "adminsetup.html"
|
||||||
pageUsersTitle = "Users"
|
pageUsersTitle = "Users"
|
||||||
pageAdminsTitle = "Admins"
|
pageAdminsTitle = "Admins"
|
||||||
pageConnectionsTitle = "Connections"
|
pageConnectionsTitle = "Connections"
|
||||||
|
@ -61,6 +62,7 @@ const (
|
||||||
pageFoldersTitle = "Folders"
|
pageFoldersTitle = "Folders"
|
||||||
pageChangePwdTitle = "Change password"
|
pageChangePwdTitle = "Change password"
|
||||||
pageMaintenanceTitle = "Maintenance"
|
pageMaintenanceTitle = "Maintenance"
|
||||||
|
pageSetupTitle = "Create first admin user"
|
||||||
defaultQueryLimit = 500
|
defaultQueryLimit = 500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -156,6 +158,12 @@ type maintenancePage struct {
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type setupPage struct {
|
||||||
|
basePage
|
||||||
|
Username string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
type folderPage struct {
|
type folderPage struct {
|
||||||
basePage
|
basePage
|
||||||
Folder vfs.BaseVirtualFolder
|
Folder vfs.BaseVirtualFolder
|
||||||
|
@ -225,6 +233,9 @@ func loadAdminTemplates(templatesPath string) {
|
||||||
filepath.Join(templatesPath, templateAdminDir, templateBase),
|
filepath.Join(templatesPath, templateAdminDir, templateBase),
|
||||||
filepath.Join(templatesPath, templateAdminDir, templateMaintenance),
|
filepath.Join(templatesPath, templateAdminDir, templateMaintenance),
|
||||||
}
|
}
|
||||||
|
setupPath := []string{
|
||||||
|
filepath.Join(templatesPath, templateAdminDir, templateSetup),
|
||||||
|
}
|
||||||
usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...))
|
usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...))
|
||||||
userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...))
|
userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...))
|
||||||
adminsTmpl := utils.LoadTemplate(template.ParseFiles(adminsPaths...))
|
adminsTmpl := utils.LoadTemplate(template.ParseFiles(adminsPaths...))
|
||||||
|
@ -237,6 +248,7 @@ func loadAdminTemplates(templatesPath string) {
|
||||||
loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...))
|
loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...))
|
||||||
changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...))
|
changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...))
|
||||||
maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...))
|
maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...))
|
||||||
|
setupTmpl := utils.LoadTemplate(template.ParseFiles(setupPath...))
|
||||||
|
|
||||||
adminTemplates[templateUsers] = usersTmpl
|
adminTemplates[templateUsers] = usersTmpl
|
||||||
adminTemplates[templateUser] = userTmpl
|
adminTemplates[templateUser] = userTmpl
|
||||||
|
@ -250,6 +262,7 @@ func loadAdminTemplates(templatesPath string) {
|
||||||
adminTemplates[templateLogin] = loginTmpl
|
adminTemplates[templateLogin] = loginTmpl
|
||||||
adminTemplates[templateChangePwd] = changePwdTmpl
|
adminTemplates[templateChangePwd] = changePwdTmpl
|
||||||
adminTemplates[templateMaintenance] = maintenanceTmpl
|
adminTemplates[templateMaintenance] = maintenanceTmpl
|
||||||
|
adminTemplates[templateSetup] = setupTmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBasePageData(title, currentURL string, r *http.Request) basePage {
|
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)
|
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,
|
func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
|
||||||
error string, isAdd bool) {
|
error string, isAdd bool) {
|
||||||
currentURL := webAdminPath
|
currentURL := webAdminPath
|
||||||
|
@ -1094,6 +1117,58 @@ func handleGetWebAdmins(w http.ResponseWriter, r *http.Request) {
|
||||||
renderAdminTemplate(w, templateAdmins, data)
|
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) {
|
func handleWebAddAdminGet(w http.ResponseWriter, r *http.Request) {
|
||||||
admin := &dataprovider.Admin{Status: 1}
|
admin := &dataprovider.Admin{Status: 1}
|
||||||
renderAddUpdateAdminPage(w, r, admin, "", true)
|
renderAddUpdateAdminPage(w, r, admin, "", true)
|
||||||
|
|
|
@ -147,6 +147,7 @@ func TestMain(m *testing.M) {
|
||||||
logger.ErrorToConsole("error creating login banner: %v", err)
|
logger.ErrorToConsole("error creating login banner: %v", err)
|
||||||
}
|
}
|
||||||
os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2")
|
os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2")
|
||||||
|
os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1")
|
||||||
err = config.LoadConfig(configDir, "")
|
err = config.LoadConfig(configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorToConsole("error loading configuration: %v", err)
|
logger.ErrorToConsole("error loading configuration: %v", err)
|
||||||
|
|
|
@ -176,7 +176,8 @@
|
||||||
},
|
},
|
||||||
"password_caching": true,
|
"password_caching": true,
|
||||||
"update_mode": 0,
|
"update_mode": 0,
|
||||||
"skip_natural_keys_validation": false
|
"skip_natural_keys_validation": false,
|
||||||
|
"create_default_admin": false
|
||||||
},
|
},
|
||||||
"httpd": {
|
"httpd": {
|
||||||
"bindings": [
|
"bindings": [
|
||||||
|
|
138
templates/webadmin/adminsetup.html
Normal file
138
templates/webadmin/adminsetup.html
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
|
||||||
|
<title>SFTPGo - Setup</title>
|
||||||
|
|
||||||
|
<link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
|
||||||
|
|
||||||
|
<!-- Custom styles for this template-->
|
||||||
|
<link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dt-buttons {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-form-error {
|
||||||
|
color: var(--red) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dt-buttons {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-form-error {
|
||||||
|
color: var(--red) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.user-custom .custom-checkbox.small label {
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.user-custom .form-control-user-custom {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 10rem;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.user-custom .btn-user-custom {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 10rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-gradient-primary">
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Outer Row -->
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
|
||||||
|
<div class="col-xl-7 col-lg-8 col-md-10">
|
||||||
|
|
||||||
|
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<!-- Nested Row within Card Body -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="h5 text-gray-900 mb-4">To start using SFTPGo you need to create an admin user</h1>
|
||||||
|
</div>
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="card mb-4 border-left-warning">
|
||||||
|
<div class="card-body text-form-error">{{.Error}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
|
||||||
|
class="user-custom">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-control form-control-user-custom" id="inputUsername"
|
||||||
|
name="username" placeholder="Username" value="{{.Username}}" maxlength="60" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" class="form-control form-control-user-custom" id="inputPassword"
|
||||||
|
name="password" placeholder="Password" maxlength="60" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" class="form-control form-control-user-custom" id="inputConfirmPassword"
|
||||||
|
name="confirm_password" placeholder="Repeat password" maxlength="60" required>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
|
||||||
|
Create admin
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap core JavaScript-->
|
||||||
|
<script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
|
||||||
|
<script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Core plugin JavaScript-->
|
||||||
|
<script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom scripts for all pages-->
|
||||||
|
<script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -99,11 +99,11 @@
|
||||||
class="user-custom">
|
class="user-custom">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control form-control-user-custom"
|
<input type="text" class="form-control form-control-user-custom"
|
||||||
id="inputUsername" name="username" placeholder="Username">
|
id="inputUsername" name="username" placeholder="Username" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control form-control-user-custom"
|
<input type="password" class="form-control form-control-user-custom"
|
||||||
id="inputPassword" name="password" placeholder="Password">
|
id="inputPassword" name="password" placeholder="Password" required>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
|
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
|
||||||
|
|
|
@ -99,11 +99,11 @@
|
||||||
class="user-custom">
|
class="user-custom">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control form-control-user-custom"
|
<input type="text" class="form-control form-control-user-custom"
|
||||||
id="inputUsername" name="username" placeholder="Username">
|
id="inputUsername" name="username" placeholder="Username" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control form-control-user-custom"
|
<input type="password" class="form-control form-control-user-custom"
|
||||||
id="inputPassword" name="password" placeholder="Password">
|
id="inputPassword" name="password" placeholder="Password" required>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
|
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
|
||||||
|
|
|
@ -245,6 +245,7 @@ var (
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
logFilePath = filepath.Join(configDir, "sftpgo_webdavd_test.log")
|
logFilePath = filepath.Join(configDir, "sftpgo_webdavd_test.log")
|
||||||
logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel)
|
logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel)
|
||||||
|
os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1")
|
||||||
err := config.LoadConfig(configDir, "")
|
err := config.LoadConfig(configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorToConsole("error loading configuration: %v", err)
|
logger.ErrorToConsole("error loading configuration: %v", err)
|
||||||
|
|
Loading…
Reference in a new issue