diff --git a/README.md b/README.md index 6d0260ee..ecc9d77b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Full featured and highly configurable SFTP server - SQLite, MySQL, PostgreSQL, bbolt (key/value store in pure Go) and in memory data providers are supported. - Public key and password authentication. Multiple public keys per user are supported. - Keyboard interactive authentication. You can easily setup a customizable multi factor authentication. +- Per user authentication methods. You can, for example, deny one or more authentication methods to one or more users. - Custom authentication using external programs is supported. - Quota support: accounts can have individual quota expressed as max total size and/or max number of files. - Bandwidth throttling is supported, with distinct settings for upload and download. @@ -687,6 +688,10 @@ For each account the following properties can be configured: - `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited. - `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32" - `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied +- `denied_login_methods`, List of login methods not allowed. The following login methods are supported: + - `publickey` + - `password` + - `keyboard-interactive` - `fs_provider`, filesystem to serve via SFTP. Local filesystem and S3 Compatible Object Storage are supported - `s3_bucket`, required for S3 filesystem - `s3_region`, required for S3 filesystem. Must match the region for your bucket. You can find here the list of available [AWS regions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions). For example if your bucket is at `Frankfurt` you have to set the region to `eu-central-1` @@ -825,7 +830,7 @@ The logs can be divided into the following categories: - `level` string - `username`, string. Can be empty if the connection is closed before an authentication attempt - `client_ip` string. - - `login_type` string. Can be `public_key`, `password` or `no_auth_tryed` + - `login_type` string. Can be `publickey`, `password`, `keyboard-interactive` or `no_auth_tryed` - `error` string. Optional error description ### Brute force protection diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 50003f9d..597df5dc 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -77,10 +77,12 @@ var ( // ValidPerms list that contains all the valid permissions for an user ValidPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermOverwrite, PermRename, PermDelete, PermCreateDirs, PermCreateSymlinks, PermChmod, PermChown, PermChtimes} - config Config - provider Provider - sqlPlaceholders []string - hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, + // ValidSSHLoginMethods list that contains all the valid SSH login methods + ValidSSHLoginMethods = []string{SSHLoginMethodPublicKey, SSHLoginMethodPassword, SSHLoginMethodKeyboardInteractive} + config Config + provider Provider + sqlPlaceholders []string + hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix} pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix} unixPwdPrefixes = []string{md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix} @@ -510,6 +512,9 @@ func validatePermissions(user *User) error { if len(perms) == 0 && dir == "/" { return &ValidationError{err: fmt.Sprintf("no permissions granted for the directory: %#v", dir)} } + if len(perms) > len(ValidPerms) { + return &ValidationError{err: "invalid permissions"} + } for _, p := range perms { if !utils.IsStringInSlice(p, ValidPerms) { return &ValidationError{err: fmt.Sprintf("invalid permission: %#v", p)} @@ -555,6 +560,9 @@ func validateFilters(user *User) error { if len(user.Filters.DeniedIP) == 0 { user.Filters.DeniedIP = []string{} } + if len(user.Filters.DeniedLoginMethods) == 0 { + user.Filters.DeniedLoginMethods = []string{} + } for _, IPMask := range user.Filters.DeniedIP { _, _, err := net.ParseCIDR(IPMask) if err != nil { @@ -567,6 +575,14 @@ func validateFilters(user *User) error { return &ValidationError{err: fmt.Sprintf("could not parse allowed IP/Mask %#v : %v", IPMask, err)} } } + if len(user.Filters.DeniedLoginMethods) >= len(ValidSSHLoginMethods) { + return &ValidationError{err: "invalid denied_login_methods"} + } + for _, loginMethod := range user.Filters.DeniedLoginMethods { + if !utils.IsStringInSlice(loginMethod, ValidSSHLoginMethods) { + return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)} + } + } return nil } diff --git a/dataprovider/user.go b/dataprovider/user.go index 51acfc25..7b744888 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -42,15 +42,25 @@ const ( PermChtimes = "chtimes" ) +// Available SSH login methods +const ( + SSHLoginMethodPublicKey = "publickey" + SSHLoginMethodPassword = "password" + SSHLoginMethodKeyboardInteractive = "keyboard-interactive" +) + // UserFilters defines additional restrictions for a user type UserFilters struct { // only clients connecting from these IP/Mask are allowed. // IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291 // for example "192.0.2.0/24" or "2001:db8::/32" - AllowedIP []string `json:"allowed_ip"` + AllowedIP []string `json:"allowed_ip,omitempty"` // clients connecting from these IP/Mask are not allowed. // Denied rules will be evaluated before allowed ones - DeniedIP []string `json:"denied_ip"` + DeniedIP []string `json:"denied_ip,omitempty"` + // these login methods are not allowed. + // If null or empty any available login method is allowed + DeniedLoginMethods []string `json:"denied_login_methods,omitempty"` } // Filesystem defines cloud storage filesystem details @@ -183,11 +193,22 @@ func (u *User) HasPerms(permissions []string, path string) bool { return true } -// IsLoginAllowed return true if the login is allowed from the specified remoteAddr. +// IsLoginMethodAllowed returns true if the specified login method is allowed for the user +func (u *User) IsLoginMethodAllowed(loginMetod string) bool { + if len(u.Filters.DeniedLoginMethods) == 0 { + return true + } + if utils.IsStringInSlice(loginMetod, u.Filters.DeniedLoginMethods) { + return false + } + return true +} + +// IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr. // If AllowedIP is defined only the specified IP/Mask can login. // If DeniedIP is defined the specified IP/Mask cannot login. // If an IP is both allowed and denied then login will be denied -func (u *User) IsLoginAllowed(remoteAddr string) bool { +func (u *User) IsLoginFromAddrAllowed(remoteAddr string) bool { if len(u.Filters.AllowedIP) == 0 && len(u.Filters.DeniedIP) == 0 { return true } @@ -407,6 +428,8 @@ func (u *User) getACopy() User { copy(filters.AllowedIP, u.Filters.AllowedIP) filters.DeniedIP = make([]string, len(u.Filters.DeniedIP)) copy(filters.DeniedIP, u.Filters.DeniedIP) + filters.DeniedLoginMethods = make([]string, len(u.Filters.DeniedLoginMethods)) + copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods) fsConfig := Filesystem{ Provider: u.FsConfig.Provider, S3Config: vfs.S3FsConfig{ diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 4391e671..7cb6c7fb 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -503,6 +503,9 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) { return errors.New("DeniedIP mismatch") } + if len(expected.Filters.DeniedLoginMethods) != len(actual.Filters.DeniedLoginMethods) { + return errors.New("Denied login methods mismatch") + } for _, IPMask := range expected.Filters.AllowedIP { if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) { return errors.New("AllowedIP contents mismatch") @@ -513,6 +516,11 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) return errors.New("DeniedIP contents mismatch") } } + for _, method := range expected.Filters.DeniedLoginMethods { + if !utils.IsStringInSlice(method, actual.Filters.DeniedLoginMethods) { + return errors.New("Denied login methods contents mismatch") + } + } return nil } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 37ea7dd1..00a9df62 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -312,6 +312,18 @@ func TestAddUserInvalidFilters(t *testing.T) { if err != nil { t.Errorf("unexpected error adding user with invalid filters: %v", err) } + u.Filters.DeniedIP = []string{} + u.Filters.DeniedLoginMethods = []string{"invalid"} + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid filters: %v", err) + } + u.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodKeyboardInteractive, + dataprovider.SSHLoginMethodPassword, dataprovider.SSHLoginMethodPublicKey} + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid filters: %v", err) + } } func TestAddUserInvalidFsConfig(t *testing.T) { @@ -409,6 +421,7 @@ func TestUpdateUser(t *testing.T) { user.Permissions["/subdir"] = []string{dataprovider.PermListItems, dataprovider.PermUpload} user.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0/24"} user.Filters.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"} + user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword} user.UploadBandwidth = 1024 user.DownloadBandwidth = 512 user, _, err = httpd.UpdateUser(user, http.StatusOK) @@ -893,7 +906,7 @@ func TestDumpdata(t *testing.T) { os.RemoveAll(credentialsPath) err = dataprovider.Initialize(providerConf, configDir) if err != nil { - t.Errorf("error initializing data provider") + t.Errorf("error initializing data provider: %v", err) } httpd.SetDataProvider(dataprovider.GetProvider()) sftpd.SetDataProvider(dataprovider.GetProvider()) @@ -1720,6 +1733,7 @@ func TestWebUserUpdateMock(t *testing.T) { form.Set("expiration_date", "2020-01-01 00:00:00") form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ") form.Set("denied_ip", " 10.0.0.2/32 ") + form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive) b, contentType, _ := getMultipartFormData(form, "", "") req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b) req.Header.Set("Content-Type", contentType) @@ -1765,6 +1779,9 @@ func TestWebUserUpdateMock(t *testing.T) { if !utils.IsStringInSlice("10.0.0.2/32", updateUser.Filters.DeniedIP) { t.Errorf("Denied IP/Mask does not match: %v", updateUser.Filters.DeniedIP) } + if !utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, updateUser.Filters.DeniedLoginMethods) { + t.Errorf("Denied login methods does not match: %v", updateUser.Filters.DeniedLoginMethods) + } req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 6ad8aef7..b1522f99 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -59,12 +59,6 @@ func TestCheckUser(t *testing.T) { t.Errorf("actual password must be nil") } actual.Password = "" - actual.PublicKeys = []string{"pub key"} - err = checkUser(expected, actual) - if err == nil { - t.Errorf("actual public key must be nil") - } - actual.PublicKeys = []string{} err = checkUser(expected, actual) if err == nil { t.Errorf("actual ID must be > 0") @@ -104,9 +98,21 @@ func TestCheckUser(t *testing.T) { } expected.Permissions = make(map[string][]string) actual.Permissions = make(map[string][]string) + actual.FsConfig.Provider = 1 + err = checkUser(expected, actual) + if err == nil { + t.Errorf("Fs providers are not equal") + } +} + +func TestCompareUserFilters(t *testing.T) { + expected := &dataprovider.User{} + actual := &dataprovider.User{} + actual.ID = 1 + expected.ID = 1 expected.Filters.AllowedIP = []string{} actual.Filters.AllowedIP = []string{"192.168.1.2/32"} - err = checkUser(expected, actual) + err := checkUser(expected, actual) if err == nil { t.Errorf("AllowedIP are not equal") } @@ -130,10 +136,16 @@ func TestCheckUser(t *testing.T) { } expected.Filters.DeniedIP = []string{} actual.Filters.DeniedIP = []string{} - actual.FsConfig.Provider = 1 + expected.Filters.DeniedLoginMethods = []string{} + actual.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey} err = checkUser(expected, actual) if err == nil { - t.Errorf("Fs providers are not equal") + t.Errorf("Denied login methods are not equal") + } + expected.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword} + err = checkUser(expected, actual) + if err == nil { + t.Errorf("Denied login methods contents are not equal") } } diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 87dee1c0..88cec3ed 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.1 info: title: SFTPGo description: 'SFTPGo REST API' - version: 1.8.0 + version: 1.8.1 servers: - url: /api/v1 @@ -927,6 +927,12 @@ components: minItems: 1 minProperties: 1 description: hash map with directory as key and an array of permissions as value. Directories must be absolute paths, permissions for root directory ("/") are required + LoginMethods: + type: string + enum: + - 'publickey' + - 'password' + - 'keyboard-interactive' UserFilters: type: object properties: @@ -944,6 +950,12 @@ components: nullable: true description: clients connecting from these IP/Mask are not allowed. Denied rules are evaluated before allowed ones example: [ "172.16.0.0/16" ] + denied_login_methods: + type: array + items: + $ref: '#/components/schemas/LoginMethods' + nullable: true + description: if null or empty any available login method is allowed description: Additional restrictions S3Config: type: object @@ -973,8 +985,6 @@ components: required: - bucket - region - - access_key - - access_secret nullable: true description: S3 Compatible Object Storage configuration details GCSConfig: diff --git a/httpd/web.go b/httpd/web.go index 3efbea26..9118f90b 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -64,12 +64,13 @@ type connectionsPage struct { type userPage struct { basePage - IsAdd bool - User dataprovider.User - RootPerms []string - Error string - ValidPerms []string - RootDirPerms []string + IsAdd bool + User dataprovider.User + RootPerms []string + Error string + ValidPerms []string + ValidSSHLoginMethods []string + RootDirPerms []string } type messagePage struct { @@ -161,24 +162,26 @@ func renderNotFoundPage(w http.ResponseWriter, err error) { func renderAddUserPage(w http.ResponseWriter, user dataprovider.User, error string) { data := userPage{ - basePage: getBasePageData("Add a new user", webUserPath), - IsAdd: true, - Error: error, - User: user, - ValidPerms: dataprovider.ValidPerms, - RootDirPerms: user.GetPermissionsForPath("/"), + basePage: getBasePageData("Add a new user", webUserPath), + IsAdd: true, + Error: error, + User: user, + ValidPerms: dataprovider.ValidPerms, + ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, + RootDirPerms: user.GetPermissionsForPath("/"), } renderTemplate(w, templateUser, data) } func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error string) { data := userPage{ - basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, user.ID)), - IsAdd: false, - Error: error, - User: user, - ValidPerms: dataprovider.ValidPerms, - RootDirPerms: user.GetPermissionsForPath("/"), + basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, user.ID)), + IsAdd: false, + Error: error, + User: user, + ValidPerms: dataprovider.ValidPerms, + ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, + RootDirPerms: user.GetPermissionsForPath("/"), } renderTemplate(w, templateUser, data) } @@ -224,6 +227,7 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters { var filters dataprovider.UserFilters filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",") + filters.DeniedLoginMethods = r.Form["ssh_login_methods"] return filters } diff --git a/metrics/metrics.go b/metrics/metrics.go index 4ca272e9..b67eb19b 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -494,7 +494,7 @@ func UpdateDataProviderAvailability(err error) { func AddLoginAttempt(authMethod string) { totalLoginAttempts.Inc() switch authMethod { - case "public_key": + case "publickey": totalKeyLoginAttempts.Inc() case "keyboard-interactive": totalInteractiveLoginAttempts.Inc() @@ -508,7 +508,7 @@ func AddLoginResult(authMethod string, err error) { if err == nil { totalLoginOK.Inc() switch authMethod { - case "public_key": + case "publickey": totalKeyLoginOK.Inc() case "keyboard-interactive": totalInteractiveLoginOK.Inc() @@ -518,7 +518,7 @@ func AddLoginResult(authMethod string, err error) { } else { totalLoginFailed.Inc() switch authMethod { - case "public_key": + case "publickey": totalKeyLoginFailed.Inc() case "keyboard-interactive": totalInteractiveLoginFailed.Inc() diff --git a/scripts/README.md b/scripts/README.md index dd3d8bfb..4576da2d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -44,7 +44,7 @@ Let's see a sample usage for each REST API. Command: ``` -python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" +python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --denied-login-methods "password" "keyboard-interactive" ``` Output: @@ -70,7 +70,10 @@ Output: "allowed_ip": [ "192.168.1.1/32" ], - "denied_ip": [] + "denied_login_methods": [ + "password", + "keyboard-interactive" + ] }, "gid": 1000, "home_dir": "/tmp/test_home_dir", @@ -112,7 +115,7 @@ Output: Command: ``` -python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --fs local +python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local ``` Output: @@ -145,7 +148,6 @@ Output: "s3config": {} }, "filters": { - "allowed_ip": [], "denied_ip": [ "192.168.1.0/24" ] @@ -198,7 +200,6 @@ Output: "s3config": {} }, "filters": { - "allowed_ip": [], "denied_ip": [ "192.168.1.0/24" ] diff --git a/scripts/sftpgo_api_cli.py b/scripts/sftpgo_api_cli.py index e988707d..8f92ac4e 100755 --- a/scripts/sftpgo_api_cli.py +++ b/scripts/sftpgo_api_cli.py @@ -76,7 +76,7 @@ class SFTPGoApiRequests: status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', - gcs_automatic_credentials='automatic'): + gcs_automatic_credentials='automatic', denied_login_methods=[]): user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid, 'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files, 'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth, @@ -92,8 +92,8 @@ class SFTPGoApiRequests: user.update({'home_dir':home_dir}) if permissions: user.update({'permissions':permissions}) - if allowed_ip or denied_ip: - user.update({'filters':self.buildFilters(allowed_ip, denied_ip)}) + if allowed_ip or denied_ip or denied_login_methods: + user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods)}) user.update({'filesystem':self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, @@ -117,7 +117,7 @@ class SFTPGoApiRequests: permissions.update({directory:values}) return permissions - def buildFilters(self, allowed_ip, denied_ip): + def buildFilters(self, allowed_ip, denied_ip, denied_login_methods): filters = {} if allowed_ip: if len(allowed_ip) == 1 and not allowed_ip[0]: @@ -129,6 +129,11 @@ class SFTPGoApiRequests: filters.update({'denied_ip':[]}) else: filters.update({'denied_ip':denied_ip}) + if denied_login_methods: + if len(denied_login_methods) == 1 and not denied_login_methods[0]: + filters.update({'denied_login_methods':[]}) + else: + filters.update({'denied_login_methods':denied_login_methods}) return filters def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, @@ -166,12 +171,13 @@ class SFTPGoApiRequests: quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', - gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic'): + gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', + denied_login_methods=[]): u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions, quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, - gcs_credentials_file, gcs_automatic_credentials) + gcs_credentials_file, gcs_automatic_credentials, denied_login_methods) r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -180,12 +186,12 @@ class SFTPGoApiRequests: expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', - gcs_automatic_credentials='automatic'): + gcs_automatic_credentials='automatic', denied_login_methods=[]): u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions, quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, - gcs_credentials_file, gcs_automatic_credentials) + gcs_credentials_file, gcs_automatic_credentials, denied_login_methods) r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -426,6 +432,8 @@ def addCommonUserArguments(parser): choices=['*', 'list', 'download', 'upload', 'overwrite', 'delete', 'rename', 'create_dirs', 'create_symlinks', 'chmod', 'chown', 'chtimes'], help='Permissions for the root directory ' +'(/). Default: %(default)s') + parser.add_argument('-L', '--denied-login-methods', type=str, nargs='+', default=[], + choices=['', 'publickey', 'password', 'keyboard-interactive'], help='Default: %(default)s') parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. ' +'For example: "/somedir:list,download" "/otherdir/subdir:*" Default: %(default)s') parser.add_argument('-U', '--upload-bandwidth', type=int, default=0, @@ -569,7 +577,8 @@ if __name__ == '__main__': args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip, args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, - args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials) + args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials, + args.denied_login_methods) elif args.command == 'update-user': api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, @@ -577,7 +586,7 @@ if __name__ == '__main__': args.subdirs_permissions, args.allowed_ip, args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, - args.gcs_credentials_file, args.gcs_automatic_credentials) + args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index e4abfb40..2a8caefd 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -438,7 +438,7 @@ func TestUploadFiles(t *testing.T) { func TestWithInvalidHome(t *testing.T) { u := dataprovider.User{} u.HomeDir = "home_rel_path" - _, err := loginUser(u, "password", "") + _, err := loginUser(u, dataprovider.SSHLoginMethodPassword, "", "") if err == nil { t.Errorf("login a user with an invalid home_dir must fail") } diff --git a/sftpd/server.go b/sftpd/server.go index 855df8ed..c0a98bf8 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -292,7 +292,7 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server // Unmarshal cannot fails here and even if it fails we'll have a user with no permissions json.Unmarshal([]byte(sconn.Permissions.Extensions["user"]), &user) - loginType := sconn.Permissions.Extensions["login_type"] + loginType := sconn.Permissions.Extensions["login_method"] connectionID := hex.EncodeToString(sconn.SessionID()) fs, err := user.GetFilesystem(connectionID) @@ -391,7 +391,7 @@ func (c Configuration) createHandler(connection Connection) sftp.Handlers { } } -func loginUser(user dataprovider.User, loginType string, remoteAddr string) (*ssh.Permissions, error) { +func loginUser(user dataprovider.User, loginMethod, remoteAddr, publicKey string) (*ssh.Permissions, error) { if !filepath.IsAbs(user.HomeDir) { logger.Warn(logSender, "", "user %#v has an invalid home dir: %#v. Home dir must be an absolute path, login not allowed", user.Username, user.HomeDir) @@ -405,9 +405,13 @@ func loginUser(user dataprovider.User, loginType string, remoteAddr string) (*ss return nil, fmt.Errorf("too many open sessions: %v", activeSessions) } } - if !user.IsLoginAllowed(remoteAddr) { + if !user.IsLoginMethodAllowed(loginMethod) { + logger.Debug(logSender, "", "cannot login user %#v, login method %#v is not allowed", user.Username, loginMethod) + return nil, fmt.Errorf("Login method %#v is not allowed for user %#v", loginMethod, user.Username) + } + if !user.IsLoginFromAddrAllowed(remoteAddr) { logger.Debug(logSender, "", "cannot login user %#v, remote address is not allowed: %v", user.Username, remoteAddr) - return nil, fmt.Errorf("Login is not allowed from this address: %v", remoteAddr) + return nil, fmt.Errorf("Login for user %#v is not allowed from this address: %v", user.Username, remoteAddr) } json, err := json.Marshal(user) @@ -415,10 +419,13 @@ func loginUser(user dataprovider.User, loginType string, remoteAddr string) (*ss logger.Warn(logSender, "", "error serializing user info: %v, authentication rejected", err) return nil, err } + if len(publicKey) > 0 { + loginMethod = fmt.Sprintf("%v: %v", loginMethod, publicKey) + } p := &ssh.Permissions{} p.Extensions = make(map[string]string) p.Extensions["user"] = string(json) - p.Extensions["login_type"] = loginType + p.Extensions["login_method"] = loginMethod return p, nil } @@ -472,11 +479,12 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe var keyID string var sshPerm *ssh.Permissions - method := "public_key" + method := dataprovider.SSHLoginMethodPublicKey metrics.AddLoginAttempt(method) if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey); err == nil { - sshPerm, err = loginUser(user, fmt.Sprintf("%v:%v", method, keyID), conn.RemoteAddr().String()) - } else { + sshPerm, err = loginUser(user, method, conn.RemoteAddr().String(), keyID) + } + if err != nil { logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) } metrics.AddLoginResult(method, err) @@ -488,11 +496,12 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [ var user dataprovider.User var sshPerm *ssh.Permissions - method := "password" + method := dataprovider.SSHLoginMethodPassword metrics.AddLoginAttempt(method) if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil { - sshPerm, err = loginUser(user, method, conn.RemoteAddr().String()) - } else { + sshPerm, err = loginUser(user, method, conn.RemoteAddr().String(), "") + } + if err != nil { logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) } metrics.AddLoginResult(method, err) @@ -504,11 +513,12 @@ func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetad var user dataprovider.User var sshPerm *ssh.Permissions - method := "keyboard-interactive" + method := dataprovider.SSHLoginMethodKeyboardInteractive metrics.AddLoginAttempt(method) if user, err = dataprovider.CheckKeyboardInteractiveAuth(dataProvider, conn.User(), c.KeyboardInteractiveProgram, client); err == nil { - sshPerm, err = loginUser(user, method, conn.RemoteAddr().String()) - } else { + sshPerm, err = loginUser(user, method, conn.RemoteAddr().String(), "") + } + if err != nil { logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) } metrics.AddLoginResult(method, err) diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 4b7b2ce3..33666fde 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -1072,6 +1072,55 @@ func TestLoginInvalidFs(t *testing.T) { os.RemoveAll(user.GetHomeDir()) } +func TestDeniedLoginMethods(t *testing.T) { + u := getTestUser(true) + u.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + _, err = getSftpClient(user, true) + if err == nil { + t.Error("public key login is disabled, authentication must fail") + } + user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodPassword} + user, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to update user: %v", err) + } + client, err := getSftpClient(user, true) + if err != nil { + t.Errorf("unable to create sftp client: %v", err) + } else { + client.Close() + } + user.Password = defaultPassword + user, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to update user: %v", err) + } + _, err = getSftpClient(user, false) + if err == nil { + t.Error("password login is disabled, authentication must fail") + } + user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodPublicKey} + user, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to update user: %v", err) + } + client, err = getSftpClient(user, false) + if err != nil { + t.Errorf("unable to create sftp client: %v", err) + } else { + client.Close() + } + _, err = httpd.RemoveUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to remove user: %v", err) + } + os.RemoveAll(user.GetHomeDir()) +} + func TestLoginWithIPFilters(t *testing.T) { usePubKey := true u := getTestUser(usePubKey) @@ -3264,53 +3313,53 @@ func TestUserEmptySubDirPerms(t *testing.T) { func TestUserFiltersIPMaskConditions(t *testing.T) { user := getTestUser(true) // with no filter login must be allowed even if the remoteIP is invalid - if !user.IsLoginAllowed("192.168.1.5") { + if !user.IsLoginFromAddrAllowed("192.168.1.5") { t.Error("unexpected login denied") } - if !user.IsLoginAllowed("invalid") { + if !user.IsLoginFromAddrAllowed("invalid") { t.Error("unexpected login denied") } user.Filters.DeniedIP = append(user.Filters.DeniedIP, "192.168.1.0/24") - if user.IsLoginAllowed("192.168.1.5") { + if user.IsLoginFromAddrAllowed("192.168.1.5") { t.Error("unexpected login allowed") } - if !user.IsLoginAllowed("192.168.2.6") { + if !user.IsLoginFromAddrAllowed("192.168.2.6") { t.Error("unexpected login denied") } user.Filters.AllowedIP = append(user.Filters.AllowedIP, "192.168.1.5/32") // if the same ip/mask is both denied and allowed then login must be denied - if user.IsLoginAllowed("192.168.1.5") { + if user.IsLoginFromAddrAllowed("192.168.1.5") { t.Error("unexpected login allowed") } - if user.IsLoginAllowed("192.168.3.6") { + if user.IsLoginFromAddrAllowed("192.168.3.6") { t.Error("unexpected login allowed") } user.Filters.DeniedIP = []string{} - if !user.IsLoginAllowed("192.168.1.5") { + if !user.IsLoginFromAddrAllowed("192.168.1.5") { t.Error("unexpected login denied") } - if user.IsLoginAllowed("192.168.1.6") { + if user.IsLoginFromAddrAllowed("192.168.1.6") { t.Error("unexpected login allowed") } user.Filters.DeniedIP = []string{"192.168.0.0/16", "172.16.0.0/16"} user.Filters.AllowedIP = []string{} - if user.IsLoginAllowed("192.168.5.255") { + if user.IsLoginFromAddrAllowed("192.168.5.255") { t.Error("unexpected login allowed") } - if user.IsLoginAllowed("172.16.1.2") { + if user.IsLoginFromAddrAllowed("172.16.1.2") { t.Error("unexpected login allowed") } - if !user.IsLoginAllowed("172.18.2.1") { + if !user.IsLoginFromAddrAllowed("172.18.2.1") { t.Error("unexpected login denied") } user.Filters.AllowedIP = []string{"10.4.4.0/24"} - if user.IsLoginAllowed("10.5.4.2") { + if user.IsLoginFromAddrAllowed("10.5.4.2") { t.Error("unexpected login allowed") } - if !user.IsLoginAllowed("10.4.4.2") { + if !user.IsLoginFromAddrAllowed("10.4.4.2") { t.Error("unexpected login denied") } - if !user.IsLoginAllowed("invalid") { + if !user.IsLoginFromAddrAllowed("invalid") { t.Error("unexpected login denied") } } diff --git a/templates/user.html b/templates/user.html index 9315c6f1..3e241a39 100644 --- a/templates/user.html +++ b/templates/user.html @@ -70,6 +70,19 @@ +
+ +
+ +
+
+