Browse Source

add support for anonymous users

Fixes #935

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 years ago
parent
commit
ef0a3bc571

+ 1 - 1
docs/groups.md

@@ -14,7 +14,7 @@ The following settings are inherited from the primary group:
 - home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username
 - home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username
 - filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix", for any vfs, and the "username" for the SFTP filesystem config
 - filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix", for any vfs, and the "username" for the SFTP filesystem config
 - max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
 - max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
-- TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication: if they are not set for the user they are replaced with the value set for the group
+- TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication, anonymous user: if they are not set for the user they are replaced with the value set for the group
 - starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username
 - starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username
 
 
 The following settings are inherited from the primary and secondary groups:
 The following settings are inherited from the primary and secondary groups:

+ 5 - 5
go.mod

@@ -51,7 +51,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.27.0
 	github.com/rs/zerolog v1.27.0
-	github.com/sftpgo/sdk v0.1.2-0.20220726072922-52d01129ff42
+	github.com/sftpgo/sdk v0.1.2-0.20220727164210-06723ba7ce9a
 	github.com/shirou/gopsutil/v3 v3.22.6
 	github.com/shirou/gopsutil/v3 v3.22.6
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/cobra v1.5.0
@@ -66,11 +66,11 @@ require (
 	go.uber.org/automaxprocs v1.5.1
 	go.uber.org/automaxprocs v1.5.1
 	gocloud.dev v0.25.0
 	gocloud.dev v0.25.0
 	golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
 	golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
-	golang.org/x/net v0.0.0-20220725212005-46097bf591d3
+	golang.org/x/net v0.0.0-20220726230323-06994584191e
 	golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c
 	golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c
-	golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f
+	golang.org/x/sys v0.0.0-20220727055044-e65921a090b8
 	golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9
 	golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9
-	google.golang.org/api v0.88.0
+	google.golang.org/api v0.89.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 )
 
 
@@ -167,5 +167,5 @@ replace (
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d
 	github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e
-	golang.org/x/net => github.com/drakkan/net v0.0.0-20220723143609-9fc59277ebad
+	golang.org/x/net => github.com/drakkan/net v0.0.0-20220727071746-ba26829f1764
 )
 )

+ 8 - 7
go.sum

@@ -263,8 +263,8 @@ github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e h1:ZvOJ5DqEUZig5lGl
 github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
 github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
-github.com/drakkan/net v0.0.0-20220723143609-9fc59277ebad h1:XmHFuEk+opBx+sd+g7sZp0cpBFocU/pf+zTSE+usbrc=
-github.com/drakkan/net v0.0.0-20220723143609-9fc59277ebad/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+github.com/drakkan/net v0.0.0-20220727071746-ba26829f1764 h1:54eBbhCnw67BW8q0rlzEjY7Lmpirh337R45gScE2Lfg=
+github.com/drakkan/net v0.0.0-20220727071746-ba26829f1764/go.mod h1:AaygXjzTFtRAg2ttMY5RMuhpJ3cNnI0XpyFJD1iQRSM=
 github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d h1:kNk/KRhszPJASp7WvjagNW254aKK643Lu8/fr4/ukiM=
 github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d h1:kNk/KRhszPJASp7WvjagNW254aKK643Lu8/fr4/ukiM=
 github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
@@ -708,8 +708,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.2-0.20220726072922-52d01129ff42 h1:I3ecUuSF9i2w/u71x1au13Frh9t30OpprXBnuozMcf4=
-github.com/sftpgo/sdk v0.1.2-0.20220726072922-52d01129ff42/go.mod h1:JB0ULmxlNNVe77TQFEULePqQzwCwD5DUmSn+lvsZqp0=
+github.com/sftpgo/sdk v0.1.2-0.20220727164210-06723ba7ce9a h1:X9qPZ+GPQ87TnBDNZN6dyX7FkjhwnFh98WgB6Y1T5O8=
+github.com/sftpgo/sdk v0.1.2-0.20220727164210-06723ba7ce9a/go.mod h1:RL4HeorXC6XgqtkLYnQUSogLdsdMfbsogIvdBVLuy4w=
 github.com/shirou/gopsutil/v3 v3.22.6 h1:FnHOFOh+cYAM0C30P+zysPISzlknLC5Z1G4EAElznfQ=
 github.com/shirou/gopsutil/v3 v3.22.6 h1:FnHOFOh+cYAM0C30P+zysPISzlknLC5Z1G4EAElznfQ=
 github.com/shirou/gopsutil/v3 v3.22.6/go.mod h1:EdIubSnZhbAvBS1yJ7Xi+AShB/hxwLHOMz4MCYz7yMs=
 github.com/shirou/gopsutil/v3 v3.22.6/go.mod h1:EdIubSnZhbAvBS1yJ7Xi+AShB/hxwLHOMz4MCYz7yMs=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@@ -971,8 +971,9 @@ golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220727055044-e65921a090b8 h1:dyU22nBWzrmTQxtNrr4dzVOvaw35nUYE279vF9UmsI8=
+golang.org/x/sys v0.0.0-20220727055044-e65921a090b8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1115,8 +1116,8 @@ google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3
 google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
 google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
 google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
 google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
 google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
 google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
-google.golang.org/api v0.88.0 h1:MPwxQRqpyskYhr2iNyfsQ8R06eeyhe7UEuR30p136ZQ=
-google.golang.org/api v0.88.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.89.0 h1:OUywo5UEEZ8H1eMy55mFpkL9Sy59mQ5TzYGWa+td8zo=
+google.golang.org/api v0.89.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=

+ 0 - 4
internal/dataprovider/bolt.go

@@ -114,10 +114,6 @@ func (p *BoltProvider) validateUserAndTLSCert(username, protocol string, tlsCert
 }
 }
 
 
 func (p *BoltProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
 func (p *BoltProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
-	var user User
-	if password == "" {
-		return user, errors.New("credentials cannot be null or empty")
-	}
 	user, err := p.userExists(username)
 	user, err := p.userExists(username)
 	if err != nil {
 	if err != nil {
 		providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
 		providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)

+ 15 - 4
internal/dataprovider/dataprovider.go

@@ -984,6 +984,12 @@ func CheckAdminAndPass(username, password, ip string) (Admin, error) {
 
 
 // CheckCachedUserCredentials checks the credentials for a cached user
 // CheckCachedUserCredentials checks the credentials for a cached user
 func CheckCachedUserCredentials(user *CachedUser, password, loginMethod, protocol string, tlsCert *x509.Certificate) error {
 func CheckCachedUserCredentials(user *CachedUser, password, loginMethod, protocol string, tlsCert *x509.Certificate) error {
+	if err := user.User.CheckLoginConditions(); err != nil {
+		return err
+	}
+	if loginMethod == LoginMethodPassword && user.User.Filters.IsAnonymous {
+		return nil
+	}
 	if loginMethod != LoginMethodPassword {
 	if loginMethod != LoginMethodPassword {
 		_, err := checkUserAndTLSCertificate(&user.User, protocol, tlsCert)
 		_, err := checkUserAndTLSCertificate(&user.User, protocol, tlsCert)
 		if err != nil {
 		if err != nil {
@@ -996,9 +1002,6 @@ func CheckCachedUserCredentials(user *CachedUser, password, loginMethod, protoco
 			return nil
 			return nil
 		}
 		}
 	}
 	}
-	if err := user.User.CheckLoginConditions(); err != nil {
-		return err
-	}
 	if password == "" {
 	if password == "" {
 		return ErrInvalidCredentials
 		return ErrInvalidCredentials
 	}
 	}
@@ -2098,6 +2101,7 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
 	filters.DisableFsChecks = in.DisableFsChecks
 	filters.DisableFsChecks = in.DisableFsChecks
 	filters.StartDirectory = in.StartDirectory
 	filters.StartDirectory = in.StartDirectory
 	filters.FTPSecurity = in.FTPSecurity
 	filters.FTPSecurity = in.FTPSecurity
+	filters.IsAnonymous = in.IsAnonymous
 	filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth
 	filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth
 	filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
 	filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
 	filters.WebClient = make([]string, len(in.WebClient))
 	filters.WebClient = make([]string, len(in.WebClient))
@@ -2608,6 +2612,9 @@ func validateBaseParams(user *User) error {
 		user.UploadDataTransfer = 0
 		user.UploadDataTransfer = 0
 		user.DownloadDataTransfer = 0
 		user.DownloadDataTransfer = 0
 	}
 	}
+	if user.Filters.IsAnonymous {
+		user.setAnonymousSettings()
+	}
 	return user.FsConfig.Validate(user.GetEncryptionAdditionalData())
 	return user.FsConfig.Validate(user.GetEncryptionAdditionalData())
 }
 }
 
 
@@ -2806,11 +2813,15 @@ func checkUserAndPass(user *User, password, ip, protocol string) (User, error) {
 	if err != nil {
 	if err != nil {
 		return *user, err
 		return *user, err
 	}
 	}
+	if user.Filters.IsAnonymous {
+		user.setAnonymousSettings()
+		return *user, nil
+	}
 	password, err = checkUserPasscode(user, password, protocol)
 	password, err = checkUserPasscode(user, password, protocol)
 	if err != nil {
 	if err != nil {
 		return *user, ErrInvalidCredentials
 		return *user, ErrInvalidCredentials
 	}
 	}
-	if user.Password == "" {
+	if user.Password == "" || password == "" {
 		return *user, errors.New("credentials cannot be null or empty")
 		return *user, errors.New("credentials cannot be null or empty")
 	}
 	}
 	if !user.Filters.Hooks.CheckPasswordDisabled {
 	if !user.Filters.Hooks.CheckPasswordDisabled {

+ 0 - 4
internal/dataprovider/memory.go

@@ -146,10 +146,6 @@ func (p *MemoryProvider) validateUserAndTLSCert(username, protocol string, tlsCe
 }
 }
 
 
 func (p *MemoryProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
 func (p *MemoryProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
-	var user User
-	if password == "" {
-		return user, errors.New("credentials cannot be null or empty")
-	}
 	user, err := p.userExists(username)
 	user, err := p.userExists(username)
 	if err != nil {
 	if err != nil {
 		providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
 		providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)

+ 0 - 4
internal/dataprovider/sqlcommon.go

@@ -744,10 +744,6 @@ func sqlCommonGetUserByUsername(username string, dbHandle sqlQuerier) (User, err
 }
 }
 
 
 func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHandle *sql.DB) (User, error) {
 func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHandle *sql.DB) (User, error) {
-	var user User
-	if password == "" {
-		return user, errors.New("credentials cannot be null or empty")
-	}
 	user, err := sqlCommonGetUserByUsername(username, dbHandle)
 	user, err := sqlCommonGetUserByUsername(username, dbHandle)
 	if err != nil {
 	if err != nil {
 		providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
 		providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)

+ 17 - 0
internal/dataprovider/user.go

@@ -373,6 +373,20 @@ func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
 	return result
 	return result
 }
 }
 
 
+func (u *User) setAnonymousSettings() {
+	for k := range u.Permissions {
+		u.Permissions[k] = []string{PermListItems, PermDownload}
+	}
+	u.Filters.DeniedProtocols = append(u.Filters.DeniedProtocols, protocolSSH, protocolHTTP)
+	u.Filters.DeniedProtocols = util.RemoveDuplicates(u.Filters.DeniedProtocols, false)
+	for _, method := range ValidLoginMethods {
+		if method != LoginMethodPassword {
+			u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, method)
+		}
+	}
+	u.Filters.DeniedLoginMethods = util.RemoveDuplicates(u.Filters.DeniedLoginMethods, false)
+}
+
 // RenderAsJSON implements the renderer interface used within plugins
 // RenderAsJSON implements the renderer interface used within plugins
 func (u *User) RenderAsJSON(reload bool) ([]byte, error) {
 func (u *User) RenderAsJSON(reload bool) ([]byte, error) {
 	if reload {
 	if reload {
@@ -1703,6 +1717,9 @@ func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters, replacer *s
 	if !u.Filters.AllowAPIKeyAuth {
 	if !u.Filters.AllowAPIKeyAuth {
 		u.Filters.AllowAPIKeyAuth = filters.AllowAPIKeyAuth
 		u.Filters.AllowAPIKeyAuth = filters.AllowAPIKeyAuth
 	}
 	}
+	if !u.Filters.IsAnonymous {
+		u.Filters.IsAnonymous = filters.IsAnonymous
+	}
 	if u.Filters.ExternalAuthCacheTime == 0 {
 	if u.Filters.ExternalAuthCacheTime == 0 {
 		u.Filters.ExternalAuthCacheTime = filters.ExternalAuthCacheTime
 		u.Filters.ExternalAuthCacheTime = filters.ExternalAuthCacheTime
 	}
 	}

+ 236 - 1
internal/ftpd/ftpd_test.go

@@ -245,6 +245,7 @@ XMf5HU3ThYqYn3bYypZZ8nQ7BXVh4LqGNqG29wR4v6l+dLO6odXnLzfApGD9e+d4
 	tlsClient2Username    = "client2"
 	tlsClient2Username    = "client2"
 	httpFsPort            = 23456
 	httpFsPort            = 23456
 	defaultHTTPFsUsername = "httpfs_ftp_user"
 	defaultHTTPFsUsername = "httpfs_ftp_user"
+	emptyPwdPlaceholder   = "empty"
 )
 )
 
 
 var (
 var (
@@ -819,6 +820,144 @@ func TestStartDirectory(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestLoginEmptyPassword(t *testing.T) {
+	u := getTestUser()
+	u.Password = ""
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	user.Password = emptyPwdPlaceholder
+
+	_, err = getFTPClient(user, true, nil)
+	assert.Error(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestAnonymousUser(t *testing.T) {
+	u := getTestUser()
+	u.Password = ""
+	u.Filters.IsAnonymous = true
+	_, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.Error(t, err)
+	user, _, err := httpdtest.GetUserByUsername(u.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.IsAnonymous)
+	assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermDownload}, user.Permissions["/"])
+	assert.Equal(t, []string{common.ProtocolSSH, common.ProtocolHTTP}, user.Filters.DeniedProtocols)
+	assert.Equal(t, []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword,
+		dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword,
+		dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.LoginMethodTLSCertificate,
+		dataprovider.LoginMethodTLSCertificateAndPwd}, user.Filters.DeniedLoginMethods)
+
+	user.Password = emptyPwdPlaceholder
+	client, err := getFTPClient(user, true, nil)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		testFileSize := int64(65535)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+
+		err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = os.Rename(testFilePath, filepath.Join(user.GetHomeDir(), testFileName))
+		assert.NoError(t, err)
+		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+		err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+		assert.NoError(t, err)
+		err = client.MakeDir("adir")
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+
+		err = client.Quit()
+		assert.NoError(t, err)
+		err = os.Remove(localDownloadPath)
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestAnonymousGroupInheritance(t *testing.T) {
+	g := getTestGroup()
+	g.UserSettings.Filters.IsAnonymous = true
+	g.UserSettings.Permissions = make(map[string][]string)
+	g.UserSettings.Permissions["/"] = allPerms
+	g.UserSettings.Permissions["/testsub"] = allPerms
+	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+	assert.NoError(t, err)
+	u := getTestUser()
+	u.Groups = []sdk.GroupMapping{
+		{
+			Name: group.Name,
+			Type: sdk.GroupTypePrimary,
+		},
+	}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	user.Password = emptyPwdPlaceholder
+	client, err := getFTPClient(user, true, nil)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		testFileSize := int64(65535)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+
+		err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = client.MakeDir("adir")
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = client.MakeDir("/testsub/adir")
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = os.Rename(testFilePath, filepath.Join(user.GetHomeDir(), testFileName))
+		assert.NoError(t, err)
+		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+		err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+		assert.NoError(t, err)
+
+		err = client.Quit()
+		assert.NoError(t, err)
+		err = os.Remove(localDownloadPath)
+		assert.NoError(t, err)
+	}
+	user.Password = defaultPassword
+	client, err = getFTPClient(user, true, nil)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+		err := client.Quit()
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestMultiFactorAuth(t *testing.T) {
 func TestMultiFactorAuth(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
@@ -1139,6 +1278,98 @@ func TestPreLoginHook(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestPreLoginHookReturningAnonymousUser(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	u := getTestUser()
+	u.Filters.IsAnonymous = true
+	u.Filters.DeniedProtocols = []string{common.ProtocolSSH}
+	u.Password = ""
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.PreLoginHook = preLoginPath
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	// the pre-login hook create the anonymous user
+	client, err := getFTPClient(u, false, nil)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		testFileSize := int64(65535)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+
+		err = client.MakeDir("tdiranonymous")
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = os.Rename(testFilePath, filepath.Join(u.GetHomeDir(), testFileName))
+		assert.NoError(t, err)
+		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+		err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+		assert.NoError(t, err)
+		err := client.Quit()
+		assert.NoError(t, err)
+	}
+
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.IsAnonymous)
+	assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermDownload}, user.Permissions["/"])
+	assert.Equal(t, []string{common.ProtocolSSH, common.ProtocolHTTP}, user.Filters.DeniedProtocols)
+	assert.Equal(t, []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword,
+		dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword,
+		dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.LoginMethodTLSCertificate,
+		dataprovider.LoginMethodTLSCertificateAndPwd}, user.Filters.DeniedLoginMethods)
+	// now the same with an existing user
+	client, err = getFTPClient(u, false, nil)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		testFileSize := int64(65535)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+
+		err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), "permission")
+		}
+		err = os.Rename(testFilePath, filepath.Join(u.GetHomeDir(), testFileName))
+		assert.NoError(t, err)
+		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+		err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+		assert.NoError(t, err)
+		err := client.Quit()
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(preLoginPath)
+	assert.NoError(t, err)
+}
+
 func TestPreDownloadHook(t *testing.T) {
 func TestPreDownloadHook(t *testing.T) {
 	if runtime.GOOS == osWindows {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")
 		t.Skip("this test is not available on Windows")
@@ -3487,7 +3718,11 @@ func getFTPClient(user dataprovider.User, useTLS bool, tlsConfig *tls.Config) (*
 	}
 	}
 	pwd := defaultPassword
 	pwd := defaultPassword
 	if user.Password != "" {
 	if user.Password != "" {
-		pwd = user.Password
+		if user.Password == emptyPwdPlaceholder {
+			pwd = ""
+		} else {
+			pwd = user.Password
+		}
 	}
 	}
 	err = client.Login(user.Username, pwd)
 	err = client.Login(user.Username, pwd)
 	if err != nil {
 	if err != nil {

+ 2 - 0
internal/ftpd/server.go

@@ -232,6 +232,8 @@ func (s *Server) PreAuthUser(cc ftpserver.ClientContext, username string) error
 			return nil
 			return nil
 		}
 		}
 		if _, ok := err.(*util.RecordNotFoundError); !ok {
 		if _, ok := err.(*util.RecordNotFoundError); !ok {
+			logger.Error(logSender, fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID()),
+				"unable to get user on pre auth: %v", err)
 			return common.ErrInternalFailure
 			return common.ErrInternalFailure
 		}
 		}
 	}
 	}

+ 63 - 0
internal/httpd/httpd_test.go

@@ -1938,6 +1938,66 @@ func TestAdminTimestamps(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestHTTPUserAuthEmptyPassword(t *testing.T) {
+	u := getTestUser()
+	u.Password = ""
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, "")
+	c := httpclient.GetHTTPClient()
+	resp, err := c.Do(req)
+	c.CloseIdleConnections()
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, "")
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "unexpected status code 401")
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestHTTPAnonymousUser(t *testing.T) {
+	u := getTestUser()
+	u.Filters.IsAnonymous = true
+	_, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.Error(t, err)
+	user, _, err := httpdtest.GetUserByUsername(u.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.IsAnonymous)
+	assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermDownload}, user.Permissions["/"])
+	assert.Equal(t, []string{common.ProtocolSSH, common.ProtocolHTTP}, user.Filters.DeniedProtocols)
+	assert.Equal(t, []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword,
+		dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword,
+		dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.LoginMethodTLSCertificate,
+		dataprovider.LoginMethodTLSCertificateAndPwd}, user.Filters.DeniedLoginMethods)
+
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	c := httpclient.GetHTTPClient()
+	resp, err := c.Do(req)
+	c.CloseIdleConnections()
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
+
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "unexpected status code 403")
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestHTTPUserAuthentication(t *testing.T) {
 func TestHTTPUserAuthentication(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -16901,6 +16961,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
 	form.Set("ftp_security", "1")
 	form.Set("ftp_security", "1")
 	form.Set("external_auth_cache_time", "0")
 	form.Set("external_auth_cache_time", "0")
 	form.Set("description", "desc %username% %password%")
 	form.Set("description", "desc %username% %password%")
+	form.Set("start_directory", "/base/%username%")
 	form.Set("vfolder_path", "/vdir%username%")
 	form.Set("vfolder_path", "/vdir%username%")
 	form.Set("vfolder_name", folder.Name)
 	form.Set("vfolder_name", folder.Name)
 	form.Set("vfolder_quota_size", "-1")
 	form.Set("vfolder_quota_size", "-1")
@@ -16956,6 +17017,8 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
 	assert.Equal(t, "desc auser2 password2", user2.Description)
 	assert.Equal(t, "desc auser2 password2", user2.Description)
 	assert.Equal(t, filepath.Join(os.TempDir(), user1.Username), user1.HomeDir)
 	assert.Equal(t, filepath.Join(os.TempDir(), user1.Username), user1.HomeDir)
 	assert.Equal(t, filepath.Join(os.TempDir(), user2.Username), user2.HomeDir)
 	assert.Equal(t, filepath.Join(os.TempDir(), user2.Username), user2.HomeDir)
+	assert.Equal(t, path.Join("/base", user1.Username), user1.Filters.StartDirectory)
+	assert.Equal(t, path.Join("/base", user2.Username), user2.Filters.StartDirectory)
 	assert.Equal(t, folder.Name, folder1.Name)
 	assert.Equal(t, folder.Name, folder1.Name)
 	assert.Equal(t, folder.MappedPath, folder1.MappedPath)
 	assert.Equal(t, folder.MappedPath, folder1.MappedPath)
 	assert.Equal(t, folder.Description, folder1.Description)
 	assert.Equal(t, folder.Description, folder1.Description)

+ 2 - 0
internal/httpd/webadmin.go

@@ -1302,6 +1302,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	if util.Contains(hooks, "check_password_disabled") {
 	if util.Contains(hooks, "check_password_disabled") {
 		filters.Hooks.CheckPasswordDisabled = true
 		filters.Hooks.CheckPasswordDisabled = true
 	}
 	}
+	filters.IsAnonymous = r.Form.Get("is_anonymous") != ""
 	filters.DisableFsChecks = r.Form.Get("disable_fs_checks") != ""
 	filters.DisableFsChecks = r.Form.Get("disable_fs_checks") != ""
 	filters.AllowAPIKeyAuth = r.Form.Get("allow_api_key_auth") != ""
 	filters.AllowAPIKeyAuth = r.Form.Get("allow_api_key_auth") != ""
 	filters.StartDirectory = r.Form.Get("start_directory")
 	filters.StartDirectory = r.Form.Get("start_directory")
@@ -1618,6 +1619,7 @@ func getUserFromTemplate(user dataprovider.User, template userTemplateFields) da
 	user.VirtualFolders = vfolders
 	user.VirtualFolders = vfolders
 	user.Description = replacePlaceholders(user.Description, replacements)
 	user.Description = replacePlaceholders(user.Description, replacements)
 	user.AdditionalInfo = replacePlaceholders(user.AdditionalInfo, replacements)
 	user.AdditionalInfo = replacePlaceholders(user.AdditionalInfo, replacements)
+	user.Filters.StartDirectory = replacePlaceholders(user.Filters.StartDirectory, replacements)
 
 
 	switch user.FsConfig.Provider {
 	switch user.FsConfig.Provider {
 	case sdk.CryptedFilesystemProvider:
 	case sdk.CryptedFilesystemProvider:

+ 3 - 0
internal/httpdtest/httpdtest.go

@@ -2069,6 +2069,9 @@ func compareUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters
 	if expected.FTPSecurity != actual.FTPSecurity {
 	if expected.FTPSecurity != actual.FTPSecurity {
 		return errors.New("ftp_security mismatch")
 		return errors.New("ftp_security mismatch")
 	}
 	}
+	if expected.IsAnonymous != actual.IsAnonymous {
+		return errors.New("is_anonymous mismatch")
+	}
 	if err := compareUserFilterSubStructs(expected, actual); err != nil {
 	if err := compareUserFilterSubStructs(expected, actual); err != nil {
 		return err
 		return err
 	}
 	}

+ 129 - 1
internal/sftpd/sftpd_test.go

@@ -2576,6 +2576,74 @@ func TestLoginWithIPFilters(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestLoginEmptyPassword(t *testing.T) {
+	usePubKey := false
+	u := getTestUser(usePubKey)
+	u.Password = ""
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	user.Password = "empty"
+	_, _, err = getSftpClient(user, usePubKey)
+	assert.Error(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestLoginAnonymousUser(t *testing.T) {
+	usePubKey := false
+	u := getTestUser(usePubKey)
+	u.Password = ""
+	u.Filters.IsAnonymous = true
+	_, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.Error(t, err)
+	user, _, err := httpdtest.GetUserByUsername(u.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.IsAnonymous)
+	assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermDownload}, user.Permissions["/"])
+	assert.Equal(t, []string{common.ProtocolSSH, common.ProtocolHTTP}, user.Filters.DeniedProtocols)
+	assert.Equal(t, []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword,
+		dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword,
+		dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.LoginMethodTLSCertificate,
+		dataprovider.LoginMethodTLSCertificateAndPwd}, user.Filters.DeniedLoginMethods)
+	_, _, err = getSftpClient(user, usePubKey)
+	assert.Error(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestAnonymousGroupInheritance(t *testing.T) {
+	g := getTestGroup()
+	g.UserSettings.Filters.IsAnonymous = true
+	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+	assert.NoError(t, err)
+	usePubKey := false
+	u := getTestUser(usePubKey)
+	u.Groups = []sdk.GroupMapping{
+		{
+			Name: group.Name,
+			Type: sdk.GroupTypePrimary,
+		},
+	}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	_, _, err = getSftpClient(user, usePubKey)
+	assert.Error(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) {
 func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) {
 	usePubKey := false
 	usePubKey := false
 	user, _, err := httpdtest.AddUser(getTestUser(usePubKey), http.StatusCreated)
 	user, _, err := httpdtest.AddUser(getTestUser(usePubKey), http.StatusCreated)
@@ -3980,6 +4048,62 @@ func TestLoginExternalAuthErrors(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestExternalAuthReturningAnonymousUser(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	usePubKey := false
+	u := getTestUser(usePubKey)
+	u.Filters.IsAnonymous = true
+	u.Password = ""
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, false, ""), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.ExternalAuthHook = extAuthPath
+	providerConf.ExternalAuthScope = 0
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	_, _, err = getSftpClient(u, usePubKey)
+	assert.Error(t, err)
+
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.IsAnonymous)
+	assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermDownload}, user.Permissions["/"])
+	assert.Equal(t, []string{common.ProtocolSSH, common.ProtocolHTTP}, user.Filters.DeniedProtocols)
+	assert.Equal(t, []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword,
+		dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword,
+		dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.LoginMethodTLSCertificate,
+		dataprovider.LoginMethodTLSCertificateAndPwd}, user.Filters.DeniedLoginMethods)
+
+	// test again, the user now exists
+	_, _, err = getSftpClient(u, usePubKey)
+	assert.Error(t, err)
+	updatedUser, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	user.UpdatedAt = updatedUser.UpdatedAt
+	assert.Equal(t, user, updatedUser)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(extAuthPath)
+	assert.NoError(t, err)
+}
+
 func TestExternalAuthPreserveMFAConfig(t *testing.T) {
 func TestExternalAuthPreserveMFAConfig(t *testing.T) {
 	if runtime.GOOS == osWindows {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")
 		t.Skip("this test is not available on Windows")
@@ -10572,7 +10696,11 @@ func getSftpClientWithAddr(user dataprovider.User, usePubKey bool, addr string)
 		config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
 		config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
 	} else {
 	} else {
 		if user.Password != "" {
 		if user.Password != "" {
-			config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)}
+			if user.Password == "empty" {
+				config.Auth = []ssh.AuthMethod{ssh.Password("")}
+			} else {
+				config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)}
+			}
 		} else {
 		} else {
 			config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)}
 			config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)}
 		}
 		}

+ 217 - 21
internal/webdavd/webdavd_test.go

@@ -240,10 +240,11 @@ D17SEQKBgCKC0GjDjnt/JvujdzHuBt1sWdOtb+B6kQvA09qVmuDF/Dq36jiaHDjg
 XMf5HU3ThYqYn3bYypZZ8nQ7BXVh4LqGNqG29wR4v6l+dLO6odXnLzfApGD9e+d4
 XMf5HU3ThYqYn3bYypZZ8nQ7BXVh4LqGNqG29wR4v6l+dLO6odXnLzfApGD9e+d4
 2tmlLP54LaN35hQxRjhT8lCN0BkrNF44+bh8frwm/kuxSd8wT2S+
 2tmlLP54LaN35hQxRjhT8lCN0BkrNF44+bh8frwm/kuxSd8wT2S+
 -----END RSA PRIVATE KEY-----`
 -----END RSA PRIVATE KEY-----`
-	testFileName       = "test_file_dav.dat"
-	testDLFileName     = "test_download_dav.dat"
-	tlsClient1Username = "client1"
-	tlsClient2Username = "client2"
+	testFileName        = "test_file_dav.dat"
+	testDLFileName      = "test_download_dav.dat"
+	tlsClient1Username  = "client1"
+	tlsClient2Username  = "client2"
+	emptyPwdPlaceholder = "empty"
 )
 )
 
 
 var (
 var (
@@ -691,6 +692,63 @@ func TestBasicHandlingCryptFs(t *testing.T) {
 	assert.Len(t, common.Connections.GetStats(), 0)
 	assert.Len(t, common.Connections.GetStats(), 0)
 }
 }
 
 
+func TestLoginEmptyPassword(t *testing.T) {
+	u := getTestUser()
+	u.Password = ""
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	user.Password = emptyPwdPlaceholder
+	client := getWebDavClient(user, false, nil)
+	err = checkBasicFunc(client)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "401")
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestAnonymousUser(t *testing.T) {
+	u := getTestUser()
+	u.Password = ""
+	u.Filters.IsAnonymous = true
+	_, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.Error(t, err)
+	user, _, err := httpdtest.GetUserByUsername(u.Username, http.StatusOK)
+	assert.NoError(t, err)
+
+	client := getWebDavClient(user, false, nil)
+	assert.NoError(t, checkBasicFunc(client))
+
+	user.Password = emptyPwdPlaceholder
+	client = getWebDavClient(user, false, nil)
+	assert.NoError(t, checkBasicFunc(client))
+
+	testFilePath := filepath.Join(homeBasePath, testFileName)
+	testFileSize := int64(65535)
+	err = createTestFile(testFilePath, testFileSize)
+	assert.NoError(t, err)
+	err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
+		false, testFileSize, client)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "403")
+	}
+	err = client.Mkdir("testdir", os.ModePerm)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "403")
+	}
+
+	err = os.Remove(testFilePath)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestLockAfterDelete(t *testing.T) {
 func TestLockAfterDelete(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
@@ -950,7 +1008,7 @@ func TestLoginExternalAuth(t *testing.T) {
 	err = config.LoadConfig(configDir, "")
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
 	providerConf := config.GetProviderConf()
-	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthScope = 0
 	providerConf.ExternalAuthScope = 0
@@ -979,6 +1037,151 @@ func TestLoginExternalAuth(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestExternalAuthReturningAnonymousUser(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	u := getTestUser()
+	u.Filters.IsAnonymous = true
+	u.Filters.DeniedProtocols = []string{common.ProtocolSSH}
+	u.Password = ""
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.ExternalAuthHook = extAuthPath
+	providerConf.ExternalAuthScope = 0
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+
+	client := getWebDavClient(u, false, nil)
+	assert.NoError(t, checkBasicFunc(client))
+
+	testFilePath := filepath.Join(homeBasePath, testFileName)
+	testFileSize := int64(65535)
+	err = createTestFile(testFilePath, testFileSize)
+	assert.NoError(t, err)
+	err = uploadFileWithRawClient(testFilePath, testFileName, u.Username, emptyPwdPlaceholder,
+		false, testFileSize, client)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "403")
+	}
+
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.IsAnonymous)
+	assert.Equal(t, []string{dataprovider.PermListItems, dataprovider.PermDownload}, user.Permissions["/"])
+	assert.Equal(t, []string{common.ProtocolSSH, common.ProtocolHTTP}, user.Filters.DeniedProtocols)
+	assert.Equal(t, []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword,
+		dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword,
+		dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.LoginMethodTLSCertificate,
+		dataprovider.LoginMethodTLSCertificateAndPwd}, user.Filters.DeniedLoginMethods)
+
+	u.Password = emptyPwdPlaceholder
+	client = getWebDavClient(user, false, nil)
+	assert.NoError(t, checkBasicFunc(client))
+
+	err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
+		false, testFileSize, client)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "403")
+	}
+	err = client.Mkdir("testdir", os.ModePerm)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "403")
+	}
+
+	err = os.Remove(testFilePath)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(extAuthPath)
+	assert.NoError(t, err)
+}
+
+func TestExternalAuthAnonymousGroupInheritance(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	g := dataprovider.Group{
+		BaseGroup: sdk.BaseGroup{
+			Name: "test_group",
+		},
+		UserSettings: dataprovider.GroupUserSettings{
+			BaseGroupUserSettings: sdk.BaseGroupUserSettings{
+				Permissions: map[string][]string{
+					"/": allPerms,
+				},
+				Filters: sdk.BaseUserFilters{
+					IsAnonymous: true,
+				},
+			},
+		},
+	}
+	u := getTestUser()
+	u.Groups = []sdk.GroupMapping{
+		{
+			Name: g.Name,
+			Type: sdk.GroupTypePrimary,
+		},
+	}
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.ExternalAuthHook = extAuthPath
+	providerConf.ExternalAuthScope = 0
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+
+	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+	assert.NoError(t, err)
+
+	u.Password = emptyPwdPlaceholder
+	client := getWebDavClient(u, false, nil)
+	assert.NoError(t, checkBasicFunc(client))
+
+	err = client.Mkdir("tdir", os.ModePerm)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "403")
+	}
+
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.False(t, user.Filters.IsAnonymous)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group, http.StatusOK)
+	assert.NoError(t, err)
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(extAuthPath)
+	assert.NoError(t, err)
+}
+
 func TestPreLoginHook(t *testing.T) {
 func TestPreLoginHook(t *testing.T) {
 	if runtime.GOOS == osWindows {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")
 		t.Skip("this test is not available on Windows")
@@ -2415,7 +2618,7 @@ func TestExternatAuthWithClientCert(t *testing.T) {
 	err = config.LoadConfig(configDir, "")
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
 	providerConf := config.GetProviderConf()
-	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthScope = 0
 	providerConf.ExternalAuthScope = 0
@@ -2826,7 +3029,11 @@ func getWebDavClient(user dataprovider.User, useTLS bool, tlsConfig *tls.Config)
 	}
 	}
 	pwd := defaultPassword
 	pwd := defaultPassword
 	if user.Password != "" {
 	if user.Password != "" {
-		pwd = user.Password
+		if user.Password == emptyPwdPlaceholder {
+			pwd = ""
+		} else {
+			pwd = user.Password
+		}
 	}
 	}
 	client := gowebdav.NewClient(rootPath, user.Username, pwd)
 	client := gowebdav.NewClient(rootPath, user.Username, pwd)
 	client.SetTimeout(10 * time.Second)
 	client.SetTimeout(10 * time.Second)
@@ -2889,24 +3096,13 @@ func getEncryptedFileSize(size int64) (int64, error) {
 	return int64(encSize) + 33, err
 	return int64(encSize) + 33, err
 }
 }
 
 
-func getExtAuthScriptContent(user dataprovider.User, nonJSONResponse bool, username string) []byte {
+func getExtAuthScriptContent(user dataprovider.User) []byte {
 	extAuthContent := []byte("#!/bin/sh\n\n")
 	extAuthContent := []byte("#!/bin/sh\n\n")
 	extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("if test \"$SFTPGO_AUTHD_USERNAME\" = \"%v\"; then\n", user.Username))...)
 	extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("if test \"$SFTPGO_AUTHD_USERNAME\" = \"%v\"; then\n", user.Username))...)
-	if len(username) > 0 {
-		user.Username = username
-	}
 	u, _ := json.Marshal(user)
 	u, _ := json.Marshal(user)
-	if nonJSONResponse {
-		extAuthContent = append(extAuthContent, []byte("echo 'text response'\n")...)
-	} else {
-		extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("echo '%v'\n", string(u)))...)
-	}
+	extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("echo '%v'\n", string(u)))...)
 	extAuthContent = append(extAuthContent, []byte("else\n")...)
 	extAuthContent = append(extAuthContent, []byte("else\n")...)
-	if nonJSONResponse {
-		extAuthContent = append(extAuthContent, []byte("echo 'text response'\n")...)
-	} else {
-		extAuthContent = append(extAuthContent, []byte("echo '{\"username\":\"\"}'\n")...)
-	}
+	extAuthContent = append(extAuthContent, []byte("echo '{\"username\":\"\"}'\n")...)
 	extAuthContent = append(extAuthContent, []byte("fi\n")...)
 	extAuthContent = append(extAuthContent, []byte("fi\n")...)
 	return extAuthContent
 	return extAuthContent
 }
 }

+ 3 - 0
openapi/openapi.yaml

@@ -4753,6 +4753,9 @@ components:
             - 0
             - 0
             - 1
             - 1
           description: 'Set to `1` to require TLS for both data and control connection. his setting is useful if you want to allow both encrypted and plain text FTP sessions globally and then you want to require encrypted sessions on a per-user basis. It has no effect if TLS is already required for all users in the configuration file.'
           description: 'Set to `1` to require TLS for both data and control connection. his setting is useful if you want to allow both encrypted and plain text FTP sessions globally and then you want to require encrypted sessions on a per-user basis. It has no effect if TLS is already required for all users in the configuration file.'
+        is_anonymous:
+          type: boolean
+          description: 'If enabled the user can login with any password or no password at all. Anonymous users are supported for FTP and WebDAV protocols and permissions will be automatically set to "list" and "download" (read only)'
       description: Additional user options
       description: Additional user options
     UserFilters:
     UserFilters:
       allOf:
       allOf:

+ 11 - 0
templates/webadmin/group.html

@@ -720,6 +720,17 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                 </div>
                                 </div>
                             </div>
                             </div>
 
 
+                            <div class="form-group">
+                                <div class="form-check">
+                                    <input type="checkbox" class="form-check-input" id="idAnonymous" name="is_anonymous"
+                                    {{if .Group.UserSettings.Filters.IsAnonymous}}checked{{end}} aria-describedby="anonymousHelpBlock">
+                                    <label for="idAnonymous" class="form-check-label">Is Anonymous</label>
+                                    <small id="anonymousHelpBlock" class="form-text text-muted">
+                                        Anonymous users are supported for FTP and WebDAV protocols and have read-only access
+                                    </small>
+                                </div>
+                            </div>
+
                             <div class="form-group">
                             <div class="form-group">
                                 <div class="form-check">
                                 <div class="form-check">
                                     <input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"
                                     <input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"

+ 11 - 0
templates/webadmin/user.html

@@ -952,6 +952,17 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                 </div>
                                 </div>
                             </div>
                             </div>
 
 
+                            <div class="form-group">
+                                <div class="form-check">
+                                    <input type="checkbox" class="form-check-input" id="idAnonymous" name="is_anonymous"
+                                    {{if .User.Filters.IsAnonymous}}checked{{end}} aria-describedby="anonymousHelpBlock">
+                                    <label for="idAnonymous" class="form-check-label">Is Anonymous</label>
+                                    <small id="anonymousHelpBlock" class="form-text text-muted">
+                                        Anonymous users are supported for FTP and WebDAV protocols and have read-only access
+                                    </small>
+                                </div>
+                            </div>
+
                             <div class="form-group">
                             <div class="form-group">
                                 <div class="form-check">
                                 <div class="form-check">
                                     <input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"
                                     <input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"