diff --git a/docs/dynamic-user-mod.md b/docs/dynamic-user-mod.md
index 0e0821af..8f315da0 100644
--- a/docs/dynamic-user-mod.md
+++ b/docs/dynamic-user-mod.md
@@ -6,7 +6,7 @@ To enable dynamic user modification, you must set the absolute path of your prog
The external program can read the following environment variables to get info about the user trying to login:
- `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON. A JSON serialized user id equal to zero means the user does not exist inside SFTPGo
-- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate`, `IDP` (external identity provider)
+- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate`, `IDP` (external identity provider) or empty if the hook is executed after receiving the FTP `USER` command
- `SFTPGO_LOGIND_IP`, ip address of the user trying to login
- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect)
@@ -35,7 +35,9 @@ If an error happens while executing the hook then login will be denied.
"Dynamic user creation or modification" and "External Authentication" are mutually exclusive, they are quite similar, the difference is that "External Authentication" returns an already authenticated user while using "Dynamic users modification" you simply create or update a user. The authentication will be checked inside SFTPGo.
In other words while using "External Authentication" the external program receives the credentials of the user trying to login (for example the cleartext password) and it needs to validate them. While using "Dynamic users modification" the pre-login program receives the user stored inside the dataprovider (it includes the hashed password if any) and it can modify it, after the modification SFTPGo will check the credentials of the user trying to login.
-For SFTPGo users (not admins) authenticating using an external identity provider such as OpenID Connect, the pre-login hook will be executed after a successful authentication against the external IDP so that you can create/update the SFTPGo user matching the one authenticated against the identity provider. This is the only case where the pre-login hook is executed even if an external authentication hook is defined.
+For SFTPGo users (not admins) authenticating using an external identity provider such as OpenID Connect, the pre-login hook will be executed after a successful authentication against the external IDP so that you can create/update the SFTPGo user matching the one authenticated against the identity provider. In this case where the pre-login hook is executed even if an external authentication hook is defined.
+
+If you enable FTP and allow both encrypted and plain text sessions, the pre-login hook is executed after receiving the FTP `USER` command. If you return an SFTPGo user with `ftp_security` set to `1` and the FTP session is not encrypted, it will be terminated. In this case where the pre-login hook is executed even if an external authentication hook is defined.
You can disable the hook on a per-user basis.
diff --git a/docs/groups.md b/docs/groups.md
index 180681c9..9ae0865e 100644
--- a/docs/groups.md
+++ b/docs/groups.md
@@ -13,7 +13,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
- 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: 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
- 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
diff --git a/go.mod b/go.mod
index d4e9825e..65b790fa 100644
--- a/go.mod
+++ b/go.mod
@@ -20,7 +20,7 @@ require (
github.com/cockroachdb/cockroach-go/v2 v2.2.15
github.com/coreos/go-oidc/v3 v3.2.0
github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
- github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e
+ github.com/fclairamb/ftpserverlib v0.18.1-0.20220726122738-5dc4d4ff4940
github.com/fclairamb/go-log v0.3.0
github.com/go-acme/lego/v4 v4.8.0
github.com/go-chi/chi/v5 v5.0.8-0.20220512131524-9e71a0d4b3d6
@@ -31,7 +31,7 @@ require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.3.0
github.com/grandcat/zeroconf v1.0.0
- github.com/hashicorp/go-hclog v1.2.1
+ github.com/hashicorp/go-hclog v1.2.2
github.com/hashicorp/go-plugin v1.4.4
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
@@ -51,7 +51,7 @@ require (
github.com/rs/cors v1.8.2
github.com/rs/xid v1.4.0
github.com/rs/zerolog v1.27.0
- github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d
+ github.com/sftpgo/sdk v0.1.2-0.20220726072922-52d01129ff42
github.com/shirou/gopsutil/v3 v3.22.6
github.com/spf13/afero v1.9.2
github.com/spf13/cobra v1.5.0
@@ -66,7 +66,7 @@ require (
go.uber.org/automaxprocs v1.5.1
gocloud.dev v0.25.0
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
- golang.org/x/net v0.0.0-20220722155237-a158d28d115b
+ golang.org/x/net v0.0.0-20220725212005-46097bf591d3
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9
@@ -111,7 +111,7 @@ require (
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
- github.com/hashicorp/yamux v0.1.0 // indirect
+ github.com/hashicorp/yamux v0.1.1 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.1.0 // indirect
@@ -138,7 +138,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
- github.com/prometheus/procfs v0.7.3 // indirect
+ github.com/prometheus/procfs v0.8.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -154,7 +154,7 @@ require (
golang.org/x/tools v0.1.11 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252 // indirect
+ google.golang.org/genproto v0.0.0-20220725144611-272f38e5d71b // indirect
google.golang.org/grpc v1.48.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
diff --git a/go.sum b/go.sum
index ca76fa8c..1acf73bd 100644
--- a/go.sum
+++ b/go.sum
@@ -281,8 +281,8 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e h1:D7/to1KmKRTTRQyExulywEVYKhB+/WOW3gqiKimrbXg=
-github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e/go.mod h1:Ff6D1Ofy7/ezi7C30NPEgazzp/AQqyp0T8D7k+Tv2ls=
+github.com/fclairamb/ftpserverlib v0.18.1-0.20220726122738-5dc4d4ff4940 h1:6/OqUlKsNadrpUMn+jZHQCp4Z5CubQqwc48pXiOlMmg=
+github.com/fclairamb/ftpserverlib v0.18.1-0.20220726122738-5dc4d4ff4940/go.mod h1:YsDCUizF6tfRwX1rQ2k4cGZk22XynMmRXh+QxOCroPc=
github.com/fclairamb/go-log v0.3.0 h1:oSC7Zjt0FZIYC5xXahUUycKGkypSdr2srFPLsp7CLd0=
github.com/fclairamb/go-log v0.3.0/go.mod h1:XG61EiPlAXnPDN8SA4N3zeA+GyBJmVOCCo12WORx/gA=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@@ -459,8 +459,8 @@ github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtng
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
-github.com/hashicorp/go-hclog v1.2.1 h1:YQsLlGDJgwhXFpucSPyVbCBviQtjlHv3jLTlp8YmtEw=
-github.com/hashicorp/go-hclog v1.2.1/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-hclog v1.2.2 h1:ihRI7YFwcZdiSD7SIenIhHfQH3OuDvWerAUBZbeQS3M=
+github.com/hashicorp/go-hclog v1.2.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.4.4 h1:NVdrSdFRt3SkZtNckJ6tog7gbpRrcbOjQi/rgF7JYWQ=
github.com/hashicorp/go-plugin v1.4.4/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s=
github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
@@ -469,8 +469,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/yamux v0.1.0 h1:DzDIF6Sd7GD2sX0kDFpHAsJMY4L+OfTvtuaQsOYXxzk=
-github.com/hashicorp/yamux v0.1.0/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
+github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
+github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
@@ -686,8 +686,9 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
+github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -707,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/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/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d h1:gpshxOhLsGFbCy4ke96X8FVMy4xvXZQChSF7dikqxp4=
-github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d/go.mod h1:JdxJrGnk6RKhRMTqwH5fFfaMiZuGi5qR1HxQaSDsswo=
+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/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/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@@ -1222,8 +1223,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW
google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252 h1:G5AjFxR+ibe9Taamo0TdW+iylfBYK10DSkHYdx7PZ9w=
-google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE=
+google.golang.org/genproto v0.0.0-20220725144611-272f38e5d71b h1:SfSkJugek6xm7lWywqth4r2iTrYLpD8lOj1nMIIhMNM=
+google.golang.org/genproto v0.0.0-20220725144611-272f38e5d71b/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go
index ef339bb4..e7c4523a 100644
--- a/internal/dataprovider/dataprovider.go
+++ b/internal/dataprovider/dataprovider.go
@@ -1193,6 +1193,25 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
return doKeyboardInteractiveAuth(&user, authHook, client, ip, protocol)
}
+// GetFTPPreAuthUser returns the SFTPGo user with the specified username
+// after receiving the FTP "USER" command.
+// If a pre-login hook is defined it will be executed so the SFTPGo user
+// can be created if it does not exist
+func GetFTPPreAuthUser(username, ip string) (User, error) {
+ var user User
+ var err error
+ if config.PreLoginHook != "" {
+ user, err = executePreLoginHook(username, "", ip, protocolFTP, nil)
+ } else {
+ user, err = UserExists(username)
+ }
+ if err != nil {
+ return user, err
+ }
+ err = user.LoadAndApplyGroupSettings()
+ return user, err
+}
+
// GetUserAfterIDPAuth returns the SFTPGo user with the specified username
// after a successful authentication with an external identity provider.
// If a pre-login hook is defined it will be executed so the SFTPGo user
@@ -2078,6 +2097,7 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
filters.Hooks.CheckPasswordDisabled = in.Hooks.CheckPasswordDisabled
filters.DisableFsChecks = in.DisableFsChecks
filters.StartDirectory = in.StartDirectory
+ filters.FTPSecurity = in.FTPSecurity
filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth
filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
filters.WebClient = make([]string, len(in.WebClient))
diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go
index cd3edf47..d6aa6307 100644
--- a/internal/dataprovider/user.go
+++ b/internal/dataprovider/user.go
@@ -1706,6 +1706,9 @@ func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters, replacer *s
if u.Filters.ExternalAuthCacheTime == 0 {
u.Filters.ExternalAuthCacheTime = filters.ExternalAuthCacheTime
}
+ if u.Filters.FTPSecurity == 0 {
+ u.Filters.FTPSecurity = filters.FTPSecurity
+ }
if u.Filters.StartDirectory == "" {
u.Filters.StartDirectory = u.replacePlaceholder(filters.StartDirectory, replacer)
}
diff --git a/internal/ftpd/ftpd_test.go b/internal/ftpd/ftpd_test.go
index 59b8d5b0..e2c13019 100644
--- a/internal/ftpd/ftpd_test.go
+++ b/internal/ftpd/ftpd_test.go
@@ -926,6 +926,61 @@ func TestLoginNonExistentUser(t *testing.T) {
assert.Error(t, err)
}
+func TestFTPSecurity(t *testing.T) {
+ u := getTestUser()
+ u.Filters.FTPSecurity = 1
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ 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 = getFTPClient(user, false, nil)
+ if assert.Error(t, err) {
+ assert.Contains(t, err.Error(), "TLS is required")
+ }
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+}
+
+func TestGroupFTPSecurity(t *testing.T) {
+ g := getTestGroup()
+ g.UserSettings.Filters.FTPSecurity = 1
+ 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)
+ 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 = getFTPClient(user, false, nil)
+ if assert.Error(t, err) {
+ assert.Contains(t, err.Error(), "TLS is required")
+ }
+ _, 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 TestLoginExternalAuth(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")
@@ -1055,6 +1110,19 @@ func TestPreLoginHook(t *testing.T) {
err := client.Quit()
assert.NoError(t, err)
}
+ user.Status = 0
+ user.Filters.FTPSecurity = 1
+ err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
+ assert.NoError(t, err)
+ client, err = getFTPClient(u, true, nil)
+ if !assert.Error(t, err) {
+ err := client.Quit()
+ assert.NoError(t, err)
+ }
+ _, err = getFTPClient(user, false, nil)
+ if assert.Error(t, err) {
+ assert.Contains(t, err.Error(), "TLS is required")
+ }
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
diff --git a/internal/ftpd/internal_test.go b/internal/ftpd/internal_test.go
index f795c2fa..5cd7b846 100644
--- a/internal/ftpd/internal_test.go
+++ b/internal/ftpd/internal_test.go
@@ -325,6 +325,10 @@ func (cc mockFTPClientContext) HasTLSForTransfers() bool {
return false
}
+func (cc mockFTPClientContext) SetTLSRequirement(requirement ftpserver.TLSRequirement) error {
+ return nil
+}
+
func (cc mockFTPClientContext) GetLastCommand() string {
return ""
}
diff --git a/internal/ftpd/server.go b/internal/ftpd/server.go
index 6a09aeb3..5adfc82b 100644
--- a/internal/ftpd/server.go
+++ b/internal/ftpd/server.go
@@ -221,6 +221,23 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
return connection, nil
}
+// PreAuthUser implements the MainDriverExtensionUserVerifier interface
+func (s *Server) PreAuthUser(cc ftpserver.ClientContext, username string) error {
+ if s.binding.TLSMode == 0 && s.tlsConfig != nil {
+ user, err := dataprovider.GetFTPPreAuthUser(username, util.GetIPFromRemoteAddress(cc.RemoteAddr().String()))
+ if err == nil {
+ if user.Filters.FTPSecurity == 1 {
+ return cc.SetTLSRequirement(ftpserver.MandatoryEncryption)
+ }
+ return nil
+ }
+ if _, ok := err.(*util.RecordNotFoundError); !ok {
+ return common.ErrInternalFailure
+ }
+ }
+ return nil
+}
+
// WrapPassiveListener implements the MainDriverExtensionPassiveWrapper interface
func (s *Server) WrapPassiveListener(listener net.Listener) (net.Listener, error) {
if s.binding.HasProxy() {
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index 423427f5..c4da700a 100644
--- a/internal/httpd/httpd_test.go
+++ b/internal/httpd/httpd_test.go
@@ -16513,6 +16513,7 @@ func TestWebUserAddMock(t *testing.T) {
assert.False(t, newUser.Filters.AllowAPIKeyAuth)
assert.Equal(t, user.Email, newUser.Email)
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
+ assert.Equal(t, 0, newUser.Filters.FTPSecurity)
assert.True(t, util.Contains(newUser.PublicKeys, testPubKey))
if val, ok := newUser.Permissions["/subdir"]; ok {
assert.True(t, util.Contains(val, dataprovider.PermListItems))
@@ -16897,6 +16898,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
form.Set("expiration_date", "2020-01-01 00:00:00")
form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0")
+ form.Set("ftp_security", "1")
form.Set("external_auth_cache_time", "0")
form.Set("description", "desc %username% %password%")
form.Set("vfolder_path", "/vdir%username%")
@@ -16963,6 +16965,8 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
assert.Len(t, user2.VirtualFolders, 1)
assert.Equal(t, "/vdirauser1", user1.VirtualFolders[0].VirtualPath)
assert.Equal(t, "/vdirauser2", user2.VirtualFolders[0].VirtualPath)
+ assert.Equal(t, 1, user1.Filters.FTPSecurity)
+ assert.Equal(t, 1, user2.Filters.FTPSecurity)
_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
assert.NoError(t, err)
@@ -17550,6 +17554,7 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("pattern_policy1", "1")
form.Set("max_upload_file_size", "0")
+ form.Set("ftp_security", "1")
form.Set("s3_force_path_style", "checked")
form.Set("description", user.Description)
form.Add("hooks", "pre_login_disabled")
@@ -17658,6 +17663,7 @@ func TestWebUserS3Mock(t *testing.T) {
assert.False(t, updateUser.Filters.Hooks.CheckPasswordDisabled)
assert.False(t, updateUser.Filters.DisableFsChecks)
assert.True(t, updateUser.Filters.AllowAPIKeyAuth)
+ assert.Equal(t, 1, updateUser.Filters.FTPSecurity)
// now check that a redacted password is not saved
form.Set("s3_access_secret", redactedSecret)
b, contentType, _ = getMultipartFormData(form, "", "")
@@ -17756,6 +17762,7 @@ func TestWebUserGCSMock(t *testing.T) {
form.Set("patterns0", "*.jpg,*.png")
form.Set("pattern_type0", "allowed")
form.Set("max_upload_file_size", "0")
+ form.Set("ftp_security", "1")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
setJWTCookieForReq(req, webToken)
@@ -17795,6 +17802,7 @@ func TestWebUserGCSMock(t *testing.T) {
assert.Contains(t, updateUser.Filters.FilePatterns[0].AllowedPatterns, "*.png")
assert.Contains(t, updateUser.Filters.FilePatterns[0].AllowedPatterns, "*.jpg")
}
+ assert.Equal(t, 1, updateUser.Filters.FTPSecurity)
form.Set("gcs_auto_credentials", "on")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index 1567ea52..46355f65 100644
--- a/internal/httpd/webadmin.go
+++ b/internal/httpd/webadmin.go
@@ -1279,6 +1279,9 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
if err != nil {
return filters, fmt.Errorf("invalid max upload file size: %w", err)
}
+ if r.Form.Get("ftp_security") == "1" {
+ filters.FTPSecurity = 1
+ }
filters.BandwidthLimits = bwLimits
filters.DataTransferLimits = dtLimits
filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index d1e3d209..c3a12aae 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/internal/httpdtest/httpdtest.go
@@ -2066,6 +2066,9 @@ func compareUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters
if expected.ExternalAuthCacheTime != actual.ExternalAuthCacheTime {
return errors.New("external_auth_cache_time mismatch")
}
+ if expected.FTPSecurity != actual.FTPSecurity {
+ return errors.New("ftp_security mismatch")
+ }
if err := compareUserFilterSubStructs(expected, actual); err != nil {
return err
}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 40bc4f7f..6dff4bfa 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -4747,6 +4747,12 @@ components:
items:
$ref: '#/components/schemas/MFAProtocols'
description: 'Defines protocols that require two factor authentication'
+ ftp_security:
+ type: integer
+ enum:
+ - 0
+ - 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: Additional user options
UserFilters:
allOf:
diff --git a/templates/webadmin/group.html b/templates/webadmin/group.html
index 1edacd96..3b740f31 100644
--- a/templates/webadmin/group.html
+++ b/templates/webadmin/group.html
@@ -690,6 +690,19 @@ along with this program. If not, see