Add support for allowed/denied IP/Mask

Login can be restricted to specific ranges of IP address or to a specific IP
address.

Please apply the appropriate SQL upgrade script to add the filter field to your
database.

The filter database field will allow to add other filters without requiring a
new database migration
This commit is contained in:
Nicola Murino 2019-12-30 18:37:50 +01:00
parent ad5436e3f6
commit 1b1c740b29
22 changed files with 623 additions and 95 deletions

View file

@ -11,7 +11,7 @@ env:
- GO111MODULE=on
before_script:
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL);'
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" TEXT NULL);'
install:
- go get -v -t ./...

View file

@ -14,6 +14,7 @@ Full featured and highly configurable SFTP server
- Per user maximum concurrent sessions.
- Per user and per directory permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled.
- Per user files/folders ownership: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (*NIX only).
- Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address.
- Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
- Automatically terminating idle connections.
- Atomic uploads are configurable.
@ -388,6 +389,8 @@ For each account the following properties can be configured:
- `chtimes` changing file or directory access and modification time is allowed
- `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited.
- `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
These properties are stored inside the data provider. If you want to use your existing accounts, you can create a database view. Since a view is read only, you have to disable user management and quota tracking so SFTPGo will never try to write to the view.

View file

@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"hash"
"net"
"net/http"
"net/url"
"os"
@ -386,6 +387,41 @@ func validatePermissions(user *User) error {
return nil
}
func validatePublicKeys(user *User) error {
if len(user.PublicKeys) == 0 {
user.PublicKeys = []string{}
}
for i, k := range user.PublicKeys {
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
if err != nil {
return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)}
}
}
return nil
}
func validateFilters(user *User) error {
if len(user.Filters.AllowedIP) == 0 {
user.Filters.AllowedIP = []string{}
}
if len(user.Filters.DeniedIP) == 0 {
user.Filters.DeniedIP = []string{}
}
for _, IPMask := range user.Filters.DeniedIP {
_, _, err := net.ParseCIDR(IPMask)
if err != nil {
return &ValidationError{err: fmt.Sprintf("Could not parse denied IP/Mask %#v : %v", IPMask, err)}
}
}
for _, IPMask := range user.Filters.AllowedIP {
_, _, err := net.ParseCIDR(IPMask)
if err != nil {
return &ValidationError{err: fmt.Sprintf("Could not parse allowed IP/Mask %#v : %v", IPMask, err)}
}
}
return nil
}
func validateUser(user *User) error {
buildUserHomeDir(user)
if len(user.Username) == 0 || len(user.HomeDir) == 0 {
@ -410,14 +446,11 @@ func validateUser(user *User) error {
}
user.Password = pwd
}
if len(user.PublicKeys) == 0 {
user.PublicKeys = []string{}
if err := validatePublicKeys(user); err != nil {
return err
}
for i, k := range user.PublicKeys {
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
if err != nil {
return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)}
}
if err := validateFilters(user); err != nil {
return err
}
return nil
}

View file

@ -158,8 +158,12 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
if err != nil {
return err
}
filters, err := user.GetFiltersAsJSON()
if err != nil {
return err
}
_, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate)
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters))
return err
}
@ -183,8 +187,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
if err != nil {
return err
}
filters, err := user.GetFiltersAsJSON()
if err != nil {
return err
}
_, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, user.ID)
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
string(filters), user.ID)
return err
}
@ -262,16 +271,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
var permissions sql.NullString
var password sql.NullString
var publicKey sql.NullString
var filters sql.NullString
var err error
if row != nil {
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status)
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters)
} else {
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status)
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters)
}
if err != nil {
if err == sql.ErrNoRows {
@ -304,5 +314,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
}
}
}
if filters.Valid {
var userFilters UserFilters
err = json.Unmarshal([]byte(filters.String), &userFilters)
if err == nil {
user.Filters = userFilters
}
} else {
user.Filters = UserFilters{
AllowedIP: []string{},
DeniedIP: []string{},
}
}
return user, err
}

View file

@ -3,8 +3,8 @@ package dataprovider
import "fmt"
const (
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions," +
"used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status"
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters"
)
func getSQLPlaceholders() []string {
@ -60,18 +60,19 @@ func getQuotaQuery() string {
func getAddUserQuery() string {
return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date)
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters)
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13])
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
sqlPlaceholders[14])
}
func getUpdateUserQuery() string {
return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v WHERE id = %v`, config.UsersTable,
sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5],
sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11],
sqlPlaceholders[12], sqlPlaceholders[13])
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v WHERE id = %v`,
config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10],
sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14])
}
func getDeleteUserQuery() string {

View file

@ -3,11 +3,13 @@ package dataprovider
import (
"encoding/json"
"fmt"
"net"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
)
@ -40,6 +42,17 @@ const (
PermChtimes = "chtimes"
)
// 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"`
// clients connecting from these IP/Mask are not allowed.
// Denied rules will be evaluated before allowed ones
DeniedIP []string `json:"denied_ip"`
}
// User defines an SFTP user
type User struct {
// Database unique identifier
@ -83,6 +96,8 @@ type User struct {
DownloadBandwidth int64 `json:"download_bandwidth"`
// Last login as unix timestamp in milliseconds
LastLogin int64 `json:"last_login"`
// Additional restrictions
Filters UserFilters `json:"filters"`
}
// GetPermissionsForPath returns the permissions for the given path
@ -144,6 +159,41 @@ func (u *User) HasPerms(permissions []string, path string) bool {
return true
}
// IsLoginAllowed return 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 {
if len(u.Filters.AllowedIP) == 0 && len(u.Filters.DeniedIP) == 0 {
return true
}
remoteIP := net.ParseIP(utils.GetIPFromRemoteAddress(remoteAddr))
// if remoteIP is invalid we allow login, this should never happen
if remoteIP == nil {
logger.Warn(logSender, "", "login allowed for invalid IP. remote address: %#v", remoteAddr)
return true
}
for _, IPMask := range u.Filters.DeniedIP {
_, IPNet, err := net.ParseCIDR(IPMask)
if err != nil {
return false
}
if IPNet.Contains(remoteIP) {
return false
}
}
for _, IPMask := range u.Filters.AllowedIP {
_, IPNet, err := net.ParseCIDR(IPMask)
if err != nil {
return false
}
if IPNet.Contains(remoteIP) {
return true
}
}
return len(u.Filters.AllowedIP) == 0
}
// GetPermissionsAsJSON returns the permissions as json byte array
func (u *User) GetPermissionsAsJSON() ([]byte, error) {
return json.Marshal(u.Permissions)
@ -154,6 +204,11 @@ func (u *User) GetPublicKeysAsJSON() ([]byte, error) {
return json.Marshal(u.PublicKeys)
}
// GetFiltersAsJSON returns the filters as json byte array
func (u *User) GetFiltersAsJSON() ([]byte, error) {
return json.Marshal(u.Filters)
}
// GetUID returns a validate uid, suitable for use with os.Chown
func (u *User) GetUID() int {
if u.UID <= 0 || u.UID > 65535 {
@ -274,6 +329,12 @@ func (u *User) GetInfoString() string {
if u.GID > 0 {
result += fmt.Sprintf("GID: %v ", u.GID)
}
if len(u.Filters.DeniedIP) > 0 {
result += fmt.Sprintf("Denied IP/Mask: %v ", len(u.Filters.DeniedIP))
}
if len(u.Filters.AllowedIP) > 0 {
result += fmt.Sprintf("Allowed IP/Mask: %v ", len(u.Filters.AllowedIP))
}
return result
}
@ -286,6 +347,30 @@ func (u *User) GetExpirationDateAsString() string {
return ""
}
// GetAllowedIPAsString returns the allowed IP as comma separated string
func (u User) GetAllowedIPAsString() string {
result := ""
for _, IPMask := range u.Filters.AllowedIP {
if len(result) > 0 {
result += ","
}
result += IPMask
}
return result
}
// GetDeniedIPAsString returns the denied IP as comma separated string
func (u User) GetDeniedIPAsString() string {
result := ""
for _, IPMask := range u.Filters.DeniedIP {
if len(result) > 0 {
result += ","
}
result += IPMask
}
return result
}
func (u *User) getACopy() User {
pubKeys := make([]string, len(u.PublicKeys))
copy(pubKeys, u.PublicKeys)
@ -295,6 +380,12 @@ func (u *User) getACopy() User {
copy(perms, v)
permissions[k] = perms
}
filters := UserFilters{}
filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
copy(filters.AllowedIP, u.Filters.AllowedIP)
filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
copy(filters.DeniedIP, u.Filters.DeniedIP)
return User{
ID: u.ID,
Username: u.Username,
@ -315,6 +406,7 @@ func (u *User) getACopy() User {
Status: u.Status,
ExpirationDate: u.ExpirationDate,
LastLogin: u.LastLogin,
Filters: filters,
}
}

View file

@ -11,6 +11,7 @@ sudo groupadd -g 1003 sftpgrp && \
sudo -u sftpuser mkdir /home/sftpuser/{conf,data} && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20190828.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191112.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191230.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sftpgo.json -o /home/sftpuser/conf/sftpgo.json
# Get and build SFTPGo image

View file

@ -97,7 +97,7 @@ func AddUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User,
body, _ = getResponseBody(resp)
}
if err == nil {
err = checkUser(user, newUser)
err = checkUser(&user, &newUser)
}
return newUser, body, err
}
@ -129,7 +129,7 @@ func UpdateUser(user dataprovider.User, expectedStatusCode int) (dataprovider.Us
newUser, body, err = GetUserByID(user.ID, expectedStatusCode)
}
if err == nil {
err = checkUser(user, newUser)
err = checkUser(&user, &newUser)
}
return newUser, body, err
}
@ -376,7 +376,7 @@ func getResponseBody(resp *http.Response) ([]byte, error) {
return ioutil.ReadAll(resp.Body)
}
func checkUser(expected dataprovider.User, actual dataprovider.User) error {
func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
if len(actual.Password) > 0 {
return errors.New("User password must not be visible")
}
@ -389,6 +389,9 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error {
return errors.New("user ID mismatch")
}
}
if len(expected.Permissions) != len(actual.Permissions) {
return errors.New("Permissions mismatch")
}
for dir, perms := range expected.Permissions {
if actualPerms, ok := actual.Permissions[dir]; ok {
for _, v := range actualPerms {
@ -400,10 +403,34 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error {
return errors.New("Permissions directories mismatch")
}
}
if err := compareUserFilters(expected, actual); err != nil {
return err
}
return compareEqualsUserFields(expected, actual)
}
func compareEqualsUserFields(expected dataprovider.User, actual dataprovider.User) error {
func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error {
if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) {
return errors.New("AllowedIP mismatch")
}
if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) {
return errors.New("DeniedIP mismatch")
}
for _, IPMask := range expected.Filters.AllowedIP {
if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) {
return errors.New("AllowedIP contents mismatch")
}
}
for _, IPMask := range expected.Filters.DeniedIP {
if !utils.IsStringInSlice(IPMask, actual.Filters.DeniedIP) {
return errors.New("DeniedIP contents mismatch")
}
}
return nil
}
func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.User) error {
if expected.Username != actual.Username {
return errors.New("Username mismatch")
}

View file

@ -217,13 +217,28 @@ func TestAddUserInvalidPerms(t *testing.T) {
u.Permissions["/"] = []string{"invalidPerm"}
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
if err != nil {
t.Errorf("unexpected error adding user with no perms: %v", err)
t.Errorf("unexpected error adding user with invalid perms: %v", err)
}
// permissions for root dir are mandatory
u.Permissions["/somedir"] = []string{dataprovider.PermAny}
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
if err != nil {
t.Errorf("unexpected error adding user with no perms: %v", err)
t.Errorf("unexpected error adding user with no root dir perms: %v", err)
}
}
func TestAddUserInvalidFilters(t *testing.T) {
u := getTestUser()
u.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0"}
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
if err != nil {
t.Errorf("unexpected error adding user with invalid filters: %v", err)
}
u.Filters.AllowedIP = []string{}
u.Filters.DeniedIP = []string{"192.168.3.0/16", "invalid"}
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
if err != nil {
t.Errorf("unexpected error adding user with invalid filters: %v", err)
}
}
@ -270,6 +285,8 @@ func TestUpdateUser(t *testing.T) {
user.QuotaFiles = 2
user.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload}
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.UploadBandwidth = 1024
user.DownloadBandwidth = 512
user, _, err = httpd.UpdateUser(user, http.StatusOK)
@ -1010,6 +1027,7 @@ func TestStartQuotaScanMock(t *testing.T) {
t.Errorf("Error get active scans: %v", err)
break
}
time.Sleep(100 * time.Millisecond)
}
_, err = os.Stat(user.HomeDir)
if err != nil && os.IsNotExist(err) {
@ -1018,6 +1036,26 @@ func TestStartQuotaScanMock(t *testing.T) {
req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr.Code)
req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
err = render.DecodeJSON(rr.Body, &scans)
if err != nil {
t.Errorf("Error get active scans: %v", err)
}
for len(scans) > 0 {
req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
err = render.DecodeJSON(rr.Body, &scans)
if err != nil {
t.Errorf("Error get active scans: %v", err)
break
}
time.Sleep(100 * time.Millisecond)
}
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
@ -1158,7 +1196,7 @@ func TestWebUserAddMock(t *testing.T) {
form.Set("status", strconv.Itoa(user.Status))
form.Set("expiration_date", "")
form.Set("permissions", "*")
form.Set("sub_dirs_permissions", "/subdir:list,download")
form.Set("sub_dirs_permissions", " /subdir:list ,download ")
// test invalid url escape
req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -1222,6 +1260,20 @@ func TestWebUserAddMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
form.Set("expiration_date", "")
form.Set("allowed_ip", "invalid,ip")
// test invalid allowed_ip
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
form.Set("allowed_ip", "")
form.Set("denied_ip", "192.168.1.2") // it should be 192.168.1.2/32
// test invalid denied_ip
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
form.Set("denied_ip", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
@ -1255,6 +1307,13 @@ func TestWebUserAddMock(t *testing.T) {
if !utils.IsStringInSlice(testPubKey, newUser.PublicKeys) {
t.Errorf("public_keys does not match")
}
if val, ok := newUser.Permissions["/subdir"]; ok {
if !utils.IsStringInSlice(dataprovider.PermListItems, val) || !utils.IsStringInSlice(dataprovider.PermDownload, val) {
t.Error("permssions for /subdir does not match")
}
} else {
t.Errorf("user permissions must contains /somedir, actual: %v", newUser.Permissions)
}
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)
@ -1285,9 +1344,11 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("upload_bandwidth", "0")
form.Set("download_bandwidth", "0")
form.Set("permissions", "*")
form.Set("sub_dirs_permissions", "/otherdir:list,upload")
form.Set("sub_dirs_permissions", "/otherdir : list ,upload ")
form.Set("status", strconv.Itoa(user.Status))
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 ")
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
@ -1319,6 +1380,19 @@ func TestWebUserUpdateMock(t *testing.T) {
if user.GID != updateUser.GID {
t.Errorf("gid does not match")
}
if val, ok := updateUser.Permissions["/otherdir"]; ok {
if !utils.IsStringInSlice(dataprovider.PermListItems, val) || !utils.IsStringInSlice(dataprovider.PermUpload, val) {
t.Error("permssions for /otherdir does not match")
}
} else {
t.Errorf("user permissions must contains /otherdir, actual: %v", updateUser.Permissions)
}
if !utils.IsStringInSlice("192.168.1.3/32", updateUser.Filters.AllowedIP) {
t.Errorf("Allowed IP/Mask does not match: %v", updateUser.Filters.AllowedIP)
}
if !utils.IsStringInSlice("10.0.0.2/32", updateUser.Filters.DeniedIP) {
t.Errorf("Denied IP/Mask does not match: %v", updateUser.Filters.DeniedIP)
}
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr.Code)

View file

@ -43,8 +43,8 @@ func TestCheckResponse(t *testing.T) {
}
func TestCheckUser(t *testing.T) {
expected := dataprovider.User{}
actual := dataprovider.User{}
expected := &dataprovider.User{}
actual := &dataprovider.User{}
actual.Password = "password"
err := checkUser(expected, actual)
if err == nil {
@ -72,6 +72,10 @@ func TestCheckUser(t *testing.T) {
expected.Permissions = make(map[string][]string)
expected.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload}
actual.Permissions = make(map[string][]string)
err = checkUser(expected, actual)
if err == nil {
t.Errorf("Permissions are not equal")
}
actual.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks}
err = checkUser(expected, actual)
if err == nil {
@ -90,11 +94,37 @@ func TestCheckUser(t *testing.T) {
if err == nil {
t.Errorf("Permissions are not equal")
}
expected.Permissions = make(map[string][]string)
actual.Permissions = make(map[string][]string)
expected.Filters.AllowedIP = []string{}
actual.Filters.AllowedIP = []string{"192.168.1.2/32"}
err = checkUser(expected, actual)
if err == nil {
t.Errorf("AllowedIP are not equal")
}
expected.Filters.AllowedIP = []string{"192.168.1.3/32"}
err = checkUser(expected, actual)
if err == nil {
t.Errorf("AllowedIP contents are not equal")
}
expected.Filters.AllowedIP = []string{}
actual.Filters.AllowedIP = []string{}
expected.Filters.DeniedIP = []string{}
actual.Filters.DeniedIP = []string{"192.168.1.2/32"}
err = checkUser(expected, actual)
if err == nil {
t.Errorf("DeniedIP are not equal")
}
expected.Filters.DeniedIP = []string{"192.168.1.3/32"}
err = checkUser(expected, actual)
if err == nil {
t.Errorf("DeniedIP contents are not equal")
}
}
func TestCompareUserFields(t *testing.T) {
expected := dataprovider.User{}
actual := dataprovider.User{}
expected := &dataprovider.User{}
actual := &dataprovider.User{}
expected.Permissions = make(map[string][]string)
actual.Permissions = make(map[string][]string)
expected.Username = "test"

View file

@ -2,7 +2,7 @@ openapi: 3.0.1
info:
title: SFTPGo
description: 'SFTPGo REST API'
version: 1.4.0
version: 1.5.0
servers:
- url: /api/v1
@ -608,7 +608,7 @@ paths:
- 2
description: >
Quota scan:
* `0` no quota scan is done, the imported user will have used_quota_size and used_quota_file = 0
* `0` no quota scan is done, the imported user will have used_quota_size and used_quota_file = 0
* `1` scan quota
* `2` scan quota if the user has quota restrictions
required: false
@ -693,6 +693,23 @@ 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
UserFilters:
type: object
properties:
allowed_ip:
type: array
items:
type: string
nullable: true
description: 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"
example: [ "192.0.2.0/24", "2001:db8::/32" ]
denied_ip:
type: array
items:
type: string
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" ]
User:
type: object
properties:
@ -743,15 +760,15 @@ components:
max_sessions:
type: integer
format: int32
description: limit the sessions that an user can open. 0 means unlimited
description: Limit the sessions that an user can open. 0 means unlimited
quota_size:
type: integer
format: int64
description: quota as size in bytes. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
description: Quota as size in bytes. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
quota_files:
type: integer
format: int32
description: quota as number of files. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
description: Quota as number of files. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
permissions:
type: object
items:
@ -767,7 +784,7 @@ components:
last_quota_update:
type: integer
format: int64
description: last quota update as unix timestamp in milliseconds
description: Last quota update as unix timestamp in milliseconds
upload_bandwidth:
type: integer
format: int32
@ -779,7 +796,11 @@ components:
last_login:
type: integer
format: int64
description: last user login as unix timestamp in milliseconds
description: Last user login as unix timestamp in milliseconds
filters:
$ref: '#/components/schemas/UserFilters'
nullable: true
description: Additional restrictions
Transfer:
type: object
properties:

View file

@ -184,12 +184,12 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
permissions := make(map[string][]string)
permissions["/"] = r.Form["permissions"]
subDirsPermsValue := r.Form.Get("sub_dirs_permissions")
for _, v := range strings.Split(subDirsPermsValue, "\n") {
cleaned := strings.TrimSpace(v)
if len(cleaned) > 0 && strings.ContainsRune(cleaned, ':') {
for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") {
if strings.ContainsRune(cleaned, ':') {
dirPerms := strings.Split(cleaned, ":")
if len(dirPerms) > 1 {
dir := dirPerms[0]
dir = strings.TrimSpace(dir)
perms := []string{}
for _, p := range strings.Split(dirPerms[1], ",") {
cleanedPerm := strings.TrimSpace(p)
@ -206,6 +206,24 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
return permissions
}
func getSliceFromDelimitedValues(values, delimiter string) []string {
result := []string{}
for _, v := range strings.Split(values, delimiter) {
cleaned := strings.TrimSpace(v)
if len(cleaned) > 0 {
result = append(result, cleaned)
}
}
return result
}
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"), ",")
return filters
}
func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
var user dataprovider.User
err := r.ParseForm()
@ -213,13 +231,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
return user, err
}
publicKeysFormValue := r.Form.Get("public_keys")
publicKeys := []string{}
for _, v := range strings.Split(publicKeysFormValue, "\n") {
cleaned := strings.TrimSpace(v)
if len(cleaned) > 0 {
publicKeys = append(publicKeys, cleaned)
}
}
publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n")
uid, err := strconv.Atoi(r.Form.Get("uid"))
if err != nil {
return user, err
@ -276,6 +288,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
DownloadBandwidth: bandwidthDL,
Status: status,
ExpirationDate: expirationDateMillis,
Filters: getFiltersFromUserPostFields(r),
}
return user, err
}

View file

@ -41,7 +41,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
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"
```
Output:
@ -50,6 +50,12 @@ Output:
{
"download_bandwidth": 60,
"expiration_date": 1546297200000,
"filters": {
"allowed_ip": [
"192.168.1.1/32"
],
"denied_ip": []
},
"gid": 1000,
"home_dir": "/tmp/test_home_dir",
"id": 9576,
@ -90,7 +96,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 ""
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"
```
Output:
@ -117,6 +123,12 @@ Output:
{
"download_bandwidth": 80,
"expiration_date": 0,
"filters": {
"allowed_ip": [],
"denied_ip": [
"192.168.1.0/24"
]
},
"gid": 33,
"home_dir": "/tmp/test_home_dir",
"id": 9576,
@ -159,6 +171,12 @@ Output:
{
"download_bandwidth": 80,
"expiration_date": 0,
"filters": {
"allowed_ip": [],
"denied_ip": [
"192.168.1.0/24"
]
},
"gid": 33,
"home_dir": "/tmp/test_home_dir",
"id": 9576,

View file

@ -70,9 +70,9 @@ class SFTPGoApiRequests:
else:
print(r.text)
def buildUserObject(self, user_id=0, username="", password="", public_keys="", home_dir="", uid=0,
def buildUserObject(self, user_id=0, username="", password="", public_keys=[], home_dir="", uid=0,
gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0,
download_bandwidth=0, status=1, expiration_date=0):
download_bandwidth=0, status=1, expiration_date=0, allowed_ip=[], denied_ip=[]):
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,
@ -88,6 +88,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)})
return user
def buildPermissions(self, root_perms, subdirs_perms):
@ -107,6 +109,20 @@ class SFTPGoApiRequests:
permissions.update({directory:values})
return permissions
def buildFilters(self, allowed_ip, denied_ip):
filters = {}
if allowed_ip:
if len(allowed_ip) == 1 and not allowed_ip[0]:
filters.update({"allowed_ip":[]})
else:
filters.update({"allowed_ip":allowed_ip})
if denied_ip:
if len(denied_ip) == 1 and not denied_ip[0]:
filters.update({"denied_ip":[]})
else:
filters.update({"denied_ip":denied_ip})
return filters
def getUsers(self, limit=100, offset=0, order="ASC", username=""):
r = requests.get(self.userPath, params={"limit":limit, "offset":offset, "order":order,
"username":username}, auth=self.auth, verify=self.verify)
@ -118,19 +134,20 @@ class SFTPGoApiRequests:
def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1,
expiration_date=0, subdirs_permissions=[]):
expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[]):
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)
status, expiration_date, allowed_ip, denied_ip)
r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
self.printResponse(r)
def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0,
max_sessions=0, quota_size=0, quota_files=0, perms=[], upload_bandwidth=0,
download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[]):
download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[],
allowed_ip=[], denied_ip=[]):
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)
status, expiration_date, allowed_ip, denied_ip)
r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
self.printResponse(r)
@ -251,7 +268,7 @@ class ConvertUsers:
user_info = spwd.getspnam(username)
password = user_info.sp_pwdp
if not password or password == '!!':
print('cannot import user "{}" without password'.format(username))
print('cannot import user "{}" without a password'.format(username))
continue
if user_info.sp_inact > 0:
last_pwd_change_diff = days_from_epoch_time - user_info.sp_lstchg
@ -283,11 +300,27 @@ class ConvertUsers:
self.addUser(self.SFTPGoRestAPI.buildUserObject(0, username, password, [], home_dir, uid, gid, 0, 0,
0, permissions, 0, 0, 1, 0))
def convertPureFTPDIP(self, fields):
result = []
if not fields:
return result
for v in fields.split(","):
ip_mask = v.strip()
if not ip_mask:
continue
if ip_mask.count(".") < 3 and ip_mask.count(":") < 3:
print("cannot import pure-ftpd IP: {}".format(ip_mask))
continue
if "/" not in ip_mask:
ip_mask += "/32"
result.append(ip_mask)
return result
def convertFromPureFTPD(self):
with open(self.input_file, 'r') as f:
for line in f:
fields = line.split(':')
if len(fields) > 13:
if len(fields) > 16:
username = fields[0]
password = fields[1]
uid = int(fields[2])
@ -308,6 +341,8 @@ class ConvertUsers:
quota_size = 0
if fields[12]:
quota_size = int(fields[12])
allowed_ip = self.convertPureFTPDIP(fields[15])
denied_ip = self.convertPureFTPDIP(fields[16])
if not self.isUserValid(username, uid, gid):
continue
if self.force_uid >= 0:
@ -317,7 +352,8 @@ class ConvertUsers:
permissions = self.SFTPGoRestAPI.buildPermissions(['*'], [])
self.addUser(self.SFTPGoRestAPI.buildUserObject(0, username, password, [], home_dir, uid, gid,
max_sessions, quota_size, quota_files, permissions,
upload_bandwidth, download_bandwidth, 1, 0))
upload_bandwidth, download_bandwidth, 1, 0, allowed_ip,
denied_ip))
def validDate(s):
@ -361,6 +397,10 @@ def addCommonUserArguments(parser):
help='User\'s status. 1 enabled, 0 disabled. Default: %(default)s')
parser.add_argument('-E', '--expiration-date', type=validDate, default="",
help='Expiration date as YYYY-MM-DD, empty string means no expiration. Default: %(default)s')
parser.add_argument('-Y', '--allowed-ip', type=str, nargs='+', default=[],
help='Allowed IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
parser.add_argument('-N', '--denied-ip', type=str, nargs='+', default=[],
help='Denied IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
if __name__ == '__main__':
@ -458,12 +498,13 @@ if __name__ == '__main__':
if args.command == 'add-user':
api.addUser(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, args.download_bandwidth,
args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions)
args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip,
args.denied_ip)
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,
args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date),
args.subdirs_permissions)
args.subdirs_permissions, args.allowed_ip, args.denied_ip)
elif args.command == 'delete-user':
api.deleteUser(args.id)
elif args.command == 'get-users':

View file

@ -174,7 +174,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, "password", "")
if err == nil {
t.Errorf("login a user with an invalid home_dir must fail")
}

View file

@ -352,12 +352,24 @@ func (c Configuration) createHandler(connection Connection) sftp.Handlers {
}
}
func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, error) {
func loginUser(user dataprovider.User, loginType string, remoteAddr 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)
return nil, fmt.Errorf("cannot login user with invalid home dir: %#v", user.HomeDir)
}
if user.MaxSessions > 0 {
activeSessions := getActiveSessions(user.Username)
if activeSessions >= user.MaxSessions {
logger.Debug(logSender, "", "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
activeSessions, user.MaxSessions)
return nil, fmt.Errorf("too many open sessions: %v", activeSessions)
}
}
if !user.IsLoginAllowed(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)
}
if _, err := os.Stat(user.HomeDir); os.IsNotExist(err) {
err := os.MkdirAll(user.HomeDir, 0777)
logger.Debug(logSender, "", "home directory %#v for user %#v does not exist, try to create, mkdir error: %v",
@ -367,15 +379,6 @@ func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, erro
}
}
if user.MaxSessions > 0 {
activeSessions := getActiveSessions(user.Username)
if activeSessions >= user.MaxSessions {
logger.Debug(logSender, "", "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
activeSessions, user.MaxSessions)
return nil, fmt.Errorf("too many open sessions: %v", activeSessions)
}
}
json, err := json.Marshal(user)
if err != nil {
logger.Warn(logSender, "", "error serializing user info: %v, authentication rejected", err)
@ -432,7 +435,7 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe
metrics.AddLoginAttempt(true)
if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey); err == nil {
sshPerm, err = loginUser(user, "public_key:"+keyID)
sshPerm, err = loginUser(user, "public_key:"+keyID, conn.RemoteAddr().String())
} else {
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "public_key", err.Error())
}
@ -447,7 +450,7 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [
metrics.AddLoginAttempt(false)
if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil {
sshPerm, err = loginUser(user, "password")
sshPerm, err = loginUser(user, "password", conn.RemoteAddr().String())
} else {
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "password", err.Error())
}

View file

@ -1014,6 +1014,60 @@ func TestLoginUserExpiration(t *testing.T) {
os.RemoveAll(user.GetHomeDir())
}
func TestLoginWithIPFilters(t *testing.T) {
usePubKey := true
u := getTestUser(usePubKey)
u.Filters.DeniedIP = []string{"192.167.0.0/24", "172.18.0.0/16"}
u.Filters.AllowedIP = []string{}
user, _, err := httpd.AddUser(u, http.StatusOK)
if err != nil {
t.Errorf("unable to add user: %v", err)
}
client, err := getSftpClient(user, usePubKey)
if err != nil {
t.Errorf("unable to create sftp client: %v", err)
} else {
defer client.Close()
_, err := client.Getwd()
if err != nil {
t.Errorf("sftp client with valid credentials must work")
}
user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
if err != nil {
t.Errorf("error getting user: %v", err)
}
if user.LastLogin <= 0 {
t.Errorf("last login must be updated after a successful login: %v", user.LastLogin)
}
}
user.Filters.AllowedIP = []string{"127.0.0.0/8"}
_, _, err = httpd.UpdateUser(user, http.StatusOK)
if err != nil {
t.Errorf("unable to update user: %v", err)
}
client, err = getSftpClient(user, usePubKey)
if err != nil {
t.Errorf("login from an allowed IP must succeed: %v", err)
} else {
defer client.Close()
}
user.Filters.AllowedIP = []string{"172.19.0.0/16"}
_, _, err = httpd.UpdateUser(user, http.StatusOK)
if err != nil {
t.Errorf("unable to update user: %v", err)
}
client, err = getSftpClient(user, usePubKey)
if err == nil {
t.Errorf("login from an not allowed IP must fail")
client.Close()
}
_, err = httpd.RemoveUser(user, http.StatusOK)
if err != nil {
t.Errorf("unable to remove user: %v", err)
}
os.RemoveAll(user.GetHomeDir())
}
func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) {
usePubKey := false
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
@ -2581,6 +2635,60 @@ func TestUserPerms(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") {
t.Error("unexpected login denied")
}
if !user.IsLoginAllowed("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") {
t.Error("unexpected login allowed")
}
if !user.IsLoginAllowed("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") {
t.Error("unexpected login allowed")
}
if user.IsLoginAllowed("192.168.3.6") {
t.Error("unexpected login allowed")
}
user.Filters.DeniedIP = []string{}
if !user.IsLoginAllowed("192.168.1.5") {
t.Error("unexpected login denied")
}
if user.IsLoginAllowed("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") {
t.Error("unexpected login allowed")
}
if user.IsLoginAllowed("172.16.1.2") {
t.Error("unexpected login allowed")
}
if !user.IsLoginAllowed("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") {
t.Error("unexpected login allowed")
}
if !user.IsLoginAllowed("10.4.4.2") {
t.Error("unexpected login denied")
}
if !user.IsLoginAllowed("invalid") {
t.Error("unexpected login denied")
}
}
func TestSSHCommands(t *testing.T) {
usePubKey := false
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
@ -2739,8 +2847,6 @@ func TestBasicGitCommands(t *testing.T) {
if err != nil {
t.Errorf("unexpected error: %v out: %v", err, string(out))
printLatestLogs(10)
out, err = pushToGitRepo(clonePath)
logger.DebugToConsole("new push out: %v, err: %v", string(out), err)
}
err = waitQuotaScans()
if err != nil {

View file

@ -261,7 +261,7 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error {
w, e := transfer.copyFromReaderToWriter(c.connection.channel.Stderr(), stderr, 0)
c.connection.Log(logger.LevelDebug, logSenderSSH, "command: %#v, copy from sdterr to remote command ended, written: %v err: %v",
c.connection.command, w, e)
// os.ErrClosed means that the command is finished so we don't need to to nothing
// os.ErrClosed means that the command is finished so we don't need to do anything
if (e != nil && !errors.Is(e, os.ErrClosed)) || w > 0 {
once.Do(closeCmdOnError)
}

6
sql/mysql/20191230.sql Normal file
View file

@ -0,0 +1,6 @@
BEGIN;
--
-- Add field filters to user
--
ALTER TABLE `users` ADD COLUMN `filters` longtext NULL;
COMMIT;

6
sql/pgsql/20191230.sql Normal file
View file

@ -0,0 +1,6 @@
BEGIN;
--
-- Add field filters to user
--
ALTER TABLE "users" ADD COLUMN "filters" text NULL;
COMMIT;

9
sql/sqlite/20191230.sql Normal file
View file

@ -0,0 +1,9 @@
BEGIN;
--
-- Add field filters to user
--
CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "filters" text NULL, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL);
INSERT INTO "new__users" ("id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", "filters") SELECT "id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", NULL FROM "users";
DROP TABLE "users";
ALTER TABLE "new__users" RENAME TO "users";
COMMIT;

View file

@ -169,6 +169,28 @@
</div>
</div>
<div class="form-group row">
<label for="idDeniedIP" class="col-sm-2 col-form-label">Denied IP/Mask</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idDeniedIP" name="denied_ip" placeholder=""
value="{{.User.GetDeniedIPAsString}}" maxlength="255" aria-describedby="deniedIPHelpBlock">
<small id="deniedIPHelpBlock" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
</div>
<div class="form-group row">
<label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idAllowedIP" name="allowed_ip" placeholder=""
value="{{.User.GetAllowedIPAsString}}" maxlength="255" aria-describedby="allowedIPHelpBlock">
<small id="allowedIPHelpBlock" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
</div>
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
</form>
@ -189,27 +211,27 @@
}
});
{{ if gt .User.ExpirationDate 0 }}
{ { if gt.User.ExpirationDate 0 } }
var input_dt = moment({{.User.ExpirationDate }}).format('YYYY-MM-DD');
$('#idExpirationDate').val(input_dt);
$('#expirationDatePicker').datetimepicker('viewDate', input_dt);
{{end}}
$('#idExpirationDate').val(input_dt);
$('#expirationDatePicker').datetimepicker('viewDate', input_dt);
{ { end } }
$("#user_form").submit(function( event ) {
var dt = $('#idExpirationDate').val();
if (dt){
var d = $('#expirationDatePicker').datetimepicker('viewDate');
if (d){
var dateString = moment(d).format('YYYY-MM-DD HH:mm:ss');
$('#hidden_start_datetime').val(dateString);
} else {
$('#hidden_start_datetime').val("");
}
$("#user_form").submit(function (event) {
var dt = $('#idExpirationDate').val();
if (dt) {
var d = $('#expirationDatePicker').datetimepicker('viewDate');
if (d) {
var dateString = moment(d).format('YYYY-MM-DD HH:mm:ss');
$('#hidden_start_datetime').val(dateString);
} else {
$('#hidden_start_datetime').val("");
}
return true;
});
} else {
$('#hidden_start_datetime').val("");
}
return true;
});
});
</script>
{{end}}