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:
Nicola Murino 2021-05-14 19:21:15 +02:00
parent 0540b8780e
commit f2b93c0402
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
20 changed files with 430 additions and 36 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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,22 +461,10 @@ 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)
if err = initializeHashingAlgo(&cnf); err != nil {
return err
}
}
if err = validateHooks(); err != nil {
return err
@ -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

View file

@ -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:

View file

@ -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.

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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)

View file

@ -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": [

View 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>

View file

@ -99,11 +99,11 @@
class="user-custom">
<div class="form-group">
<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 class="form-group">
<input type="password" class="form-control form-control-user-custom"
id="inputPassword" name="password" placeholder="Password">
id="inputPassword" name="password" placeholder="Password" required>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary btn-user-custom btn-block">

View file

@ -99,11 +99,11 @@
class="user-custom">
<div class="form-group">
<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 class="form-group">
<input type="password" class="form-control form-control-user-custom"
id="inputPassword" name="password" placeholder="Password">
id="inputPassword" name="password" placeholder="Password" required>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary btn-user-custom btn-block">

View file

@ -245,6 +245,7 @@ var (
func TestMain(m *testing.M) {
logFilePath = filepath.Join(configDir, "sftpgo_webdavd_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)