diff --git a/docs/dynamic-user-mod.md b/docs/dynamic-user-mod.md index 0e0821af811bc5e87b0b7e1aa2c71c3743ec8b49..8f315da0cf4131903182778fe00c0b253b715a4c 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 180681c9fa3aeecae8a606f1c581720419e6a6c7..9ae0865ea82c82420811d743e2e2f89d5bd5c3aa 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 d4e9825e2726cef60b36b7aaf74ee4663be71c25..65b790faadb8a00d6759d2d255d3afa4fc58f7cb 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 ca76fa8c7fcf236fd3e0b00c0dc49bafff7173e7..1acf73bdddfe8af012cad50cfc0a6784f4ef8e07 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 ef339bb4c0cfd79fcef5475fff674a9f3c0433a3..e7c4523a3bd87f9ea727938caf5b8f4d254d93be 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 cd3edf473d489305f5f672f0c910007e3cbf51ba..d6aa6307de254a5bcc69f20fa5bb7a22026eeb80 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 59b8d5b02782d145f90bdba26ac62e61a7265012..e2c130199dbae1e401b2ba5826c215b3c5fa1619 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 f795c2fade354243718cd1404c5e3d3889658a7b..5cd7b8469a22264c071c96d971c8b756b723901d 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 6a09aeb3af904baa65d64f74100b3375484448ed..5adfc82bd2ceef72962cef7018a12bb8396cbe5b 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 423427f50f61f55756e637293c8b4704515d18a2..c4da700a31c99acabb274eeb0277536bec90a7f2 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 1567ea527cc400a4410924f75a10049269983132..46355f656b2baa2ec832ce16ed70112a5a486f8b 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 d1e3d209d820f673e93b3bb84202002d370cfae1..c3a12aaec1c753d49627c639ac32e2d45b340559 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 40bc4f7fd05de17fa15dacade862e7a08062f88c..6dff4bfac714805a6b17224e6cae52f279c3ec7d 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 1edacd96cef04f746a9111a5979f9645cce61d48..3b740f310326d520760e622bd31075976ca04c45 100644 --- a/templates/webadmin/group.html +++ b/templates/webadmin/group.html @@ -690,6 +690,19 @@ along with this program. If not, see . +
+ +
+ + + Ignored if TLS is globally required for all FTP users + +
+
+
diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 582e03b7529169a92a9714985cd7426c95d0e6df..ee9856b9d54085a50ebef1dc0f59ff9953fb65cd 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -908,6 +908,19 @@ along with this program. If not, see .
+
+ +
+ + + Ignored if TLS is globally required for all FTP users + +
+
+