diff --git a/README.md b/README.md index 6bb7c550..23fb3178 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Full featured and highly configurable SFTP server - 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. +- Custom authentication via external programs is supported. +- Dynamic users modifications before login via external programs are 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. - Per user maximum concurrent sessions. @@ -179,9 +180,10 @@ The `sftpgo` configuration file contains the following sections: - `execute_on`, list of strings. Valid values are `add`, `update`, `delete`. `update` action will not be fired for internal updates such as the last login or the user quota fields. - `command`, string. Absolute path to the command to execute. Leave empty to disable. - `http_notification_url`, a valid URL. Leave empty to disable. - - `external_auth_program`, string. Absolute path to an external program to use for users authentication. See the "External Authentication" paragraph for more details. + - `external_auth_program`, string. Absolute path to an external program to use for users authentication. See the "External Authentication" paragraph for more details. Leave empty to disable. - `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive - `credentials_path`, string. It defines the directory for storing user provided credential files such as Google Cloud Storage credentials. This can be an absolute path or a path relative to the config dir + - `before_login_program`, string. Absolute path to an external program to use to modify user details just before the login. See the "Dynamic users modifications" paragraph for more details. Leave empty to disable. - **"httpd"**, the configuration for the HTTP server used to serve REST API - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 8080 - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1" @@ -240,7 +242,8 @@ Here is a full example showing the default config in JSON format: }, "external_auth_program": "", "external_auth_scope": 0, - "credentials_path": "credentials" + "credentials_path": "credentials", + "before_login_program": "" }, "httpd": { "bind_port": 8080, @@ -369,7 +372,7 @@ The external program can read the following environment variables to get info ab - `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive authentication Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user. -The program must respond on the standard output with a valid SFTPGo user serialized as JSON if the authentication succeed or an user with an empty username if the authentication fails. +The program must write, on its standard output, a valid SFTPGo user serialized as JSON if the authentication succeed or an user with an empty username if the authentication fails. If the authentication succeed the user will be automatically added/updated inside the defined data provider. Actions defined for user added/updated will not be executed in this case. The external program should check authentication only, if there are login restrictions such as user disabled, expired, login allowed only from specific IP addresses it is enough to populate the matching user fields and these conditions will be checked in the same way as for built-in users. The external auth program should finish very quickly, anyway it will be killed if it does not exit within 60 seconds. @@ -395,7 +398,46 @@ else fi ``` -If you have an external authentication program that could be useful for others too, for example LDAP/Active Directory authentication, please let us know and/or send a pull request. +If you have an external authentication program that could be useful for others too, please let us know and/or send a pull request. + +## Dynamic users modifications + +Dynamic users modifications are supported via an external program that can be executed just before the user login. +To enable dynamic users modifications you must set the absolute path of your program using the `before_login_program` key in your configuration file. + +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 +- `SFTPGO_LOGIND_METHOD`, possibile values are: `password`, `publickey` and `keyboard-interactive` + +The program must write, on its the standard output, an empty string (or no response at all) if no user update is needed or with a valid SFTPGo user serialized as JSON. +The JSON response can include only the fields that need to the updated instead of the full user, for example if you want to disable the user you can return a response like this: + +```json +{"status": 0} +``` +The external program must finish within 60 seconds. + +If an error happen while executing your program then login will be denied. "Dynamic users modifications" and "External Authentication" are mutally exclusive. + +Let's see a very basic example. Our sample program will grant access to the user `test_user` only in the time range 10:00-18:00. Other users will not be modified since the program will terminate with no output. + +``` +#!/bin/bash + +CURRENT_TIME=`date +%H:%M` +if [[ "$SFTPGO_LOGIND_USER" =~ "\"test_user\"" ]] +then + if [[ $CURRENT_TIME > "18:00" || $CURRENT_TIME < "10:00" ]] + then + echo '{"status":0}' + else + echo '{"status":1}' + fi +fi +``` + +Please note that this is a demo program and it could not work in all cases, for example the username should be obtained parsing the JSON serialized user and not searching the username inside the JSON as showed here. ## Keyboard Interactive Authentication diff --git a/config/config.go b/config/config.go index 46541527..0c14bb91 100644 --- a/config/config.go +++ b/config/config.go @@ -85,6 +85,7 @@ func init() { ExternalAuthProgram: "", ExternalAuthScope: 0, CredentialsPath: "credentials", + BeforeLoginProgram: "", }, HTTPDConfig: httpd.Conf{ BindPort: 8080, diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 71fabf2c..9ea4835f 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -170,14 +170,14 @@ type Config struct { // The content of these variables is _not_ quoted. They may contain special characters. They are under the // control of a possibly malicious remote user. // - // The program must respond on the standard output with a valid SFTPGo user serialized as json if the + // The program must respond on the standard output with a valid SFTPGo user serialized as JSON if the // authentication succeed or an user with an empty username if the authentication fails. // If the authentication succeed the user will be automatically added/updated inside the defined data provider. // Actions defined for user added/updated will not be executed in this case. // The external program should check authentication only, if there are login restrictions such as user // disabled, expired, login allowed only from specific IP addresses it is enough to populate the matching user // fields and these conditions will be checked in the same way as for builtin users. - // The external auth program must finish within 15 seconds. + // The external auth program must finish within 60 seconds. // This method is slower than built-in authentication methods, but it's very flexible as anyone can // easily write his own authentication programs. ExternalAuthProgram string `json:"external_auth_program" mapstructure:"external_auth_program"` @@ -194,6 +194,30 @@ type Config struct { // Google Cloud Storage credentials. It can be a path relative to the config dir or an // absolute path CredentialsPath string `json:"credentials_path" mapstructure:"credentials_path"` + // Absolute path to an external program to start just before the user login. + // This program will be started before an existing user try to login and allows to + // modify the user. + // It is useful if you have users with dynamic fields that need to the updated just + // before the login. + // The external program can read the following environment variables: + // + // - SFTPGO_LOGIND_USER, it contains the user trying to login serialized as JSON + // - SFTPGO_LOGIND_METHOD, possibile values are: "password", "publickey" and "keyboard-interactive" + // + // The program must respond on the standard output with an empty string if no user + // update is needed or with a valid SFTPGo user serialized as JSON. + // The JSON response can include only the fields that need to the updated instead + // of the full user, for example if you want to disable the user you can return a + // response like this: + // + // {"status":0} + // + // The external program must finish within 60 seconds. + // + // If an error happen while executing the "BeforeLoginProgram" then login will be denied. + // BeforeLoginProgram and ExternalAuthProgram are mutally exclusive. + // Leave empty to disable. + BeforeLoginProgram string `json:"before_login_program" mapstructure:"before_login_program"` } // BackupData defines the structure for the backup/restore files @@ -292,6 +316,16 @@ func Initialize(cnf Config, basePath string) error { return err } } + if len(config.BeforeLoginProgram) > 0 { + if !filepath.IsAbs(config.BeforeLoginProgram) { + return fmt.Errorf("invalid pre login program: %#v must be an absolute path", config.BeforeLoginProgram) + } + _, err := os.Stat(config.BeforeLoginProgram) + if err != nil { + providerLog(logger.LevelWarn, "invalid pre login program: %v", err) + return err + } + } if err = validateCredentialsDir(basePath); err != nil { return err } @@ -332,6 +366,13 @@ func CheckUserAndPass(p Provider, username string, password string) (User, error } return checkUserAndPass(user, password) } + if len(config.BeforeLoginProgram) > 0 { + user, err := executeBeforeLoginProgram(username, SSHLoginMethodPassword) + if err != nil { + return user, err + } + return checkUserAndPass(user, password) + } return p.validateUserAndPass(username, password) } @@ -344,6 +385,13 @@ func CheckUserAndPubKey(p Provider, username string, pubKey string) (User, strin } return checkUserAndPubKey(user, pubKey) } + if len(config.BeforeLoginProgram) > 0 { + user, err := executeBeforeLoginProgram(username, SSHLoginMethodPublicKey) + if err != nil { + return user, "", err + } + return checkUserAndPubKey(user, pubKey) + } return p.validateUserAndPubKey(username, pubKey) } @@ -354,6 +402,8 @@ func CheckKeyboardInteractiveAuth(p Provider, username, authProgram string, clie var err error if len(config.ExternalAuthProgram) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) { user, err = doExternalAuth(username, "", "", "1") + } else if len(config.BeforeLoginProgram) > 0 { + user, err = executeBeforeLoginProgram(username, SSHLoginMethodKeyboardInteractive) } else { user, err = p.userExists(username) } @@ -1086,9 +1136,60 @@ func doKeyboardInteractiveAuth(user User, authProgram string, client ssh.Keyboar if authResult != 1 { return user, fmt.Errorf("keyboard interactive auth failed, result: %v", authResult) } + err = checkLoginConditions(user) + if err != nil { + return user, err + } return user, nil } +func executeBeforeLoginProgram(username, loginMethod string) (User, error) { + u, err := provider.userExists(username) + if err != nil { + return u, err + } + userAsJSON, err := json.Marshal(u) + if err != nil { + return u, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, config.BeforeLoginProgram) + cmd.Env = append(os.Environ(), + fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)), + fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod)) + out, err := cmd.Output() + if err != nil { + return u, fmt.Errorf("Before login program error: %v", err) + } + if len(strings.TrimSpace(string(out))) == 0 { + providerLog(logger.LevelDebug, "empty response from before login program, no modification needed for user %#v", username) + return u, nil + } + + userID := u.ID + userUsedQuotaSize := u.UsedQuotaSize + userUsedQuotaFiles := u.UsedQuotaFiles + userLastQuotaUpdate := u.LastQuotaUpdate + userLastLogin := u.LastLogin + err = json.Unmarshal(out, &u) + if err != nil { + return u, fmt.Errorf("Invalid before login program response %#v, error: %v", string(out), err) + } + u.ID = userID + u.UsedQuotaSize = userUsedQuotaSize + u.UsedQuotaFiles = userUsedQuotaFiles + u.LastQuotaUpdate = userLastQuotaUpdate + u.LastLogin = userLastLogin + err = provider.updateUser(u) + if err != nil { + return u, err + } + providerLog(logger.LevelDebug, "user %#v updated from before login program response", username) + return provider.userExists(username) +} + func doExternalAuth(username, password, pubKey, keyboardInteractive string) (User, error) { var user User ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index d9ef596c..629adb85 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -89,17 +89,18 @@ iixITGvaNZh/tjAAAACW5pY29sYUBwMQE= ) var ( - allPerms = []string{dataprovider.PermAny} - homeBasePath string - scpPath string - gitPath string - sshPath string - pubKeyPath string - privateKeyPath string - gitWrapPath string - extAuthPath string - keyIntAuthPath string - logFilePath string + allPerms = []string{dataprovider.PermAny} + homeBasePath string + scpPath string + gitPath string + sshPath string + pubKeyPath string + privateKeyPath string + gitWrapPath string + extAuthPath string + keyIntAuthPath string + beforeLoginPath string + logFilePath string ) func TestMain(m *testing.M) { @@ -171,6 +172,7 @@ func TestMain(m *testing.M) { privateKeyPath = filepath.Join(homeBasePath, "ssh_key") gitWrapPath = filepath.Join(homeBasePath, "gitwrap.sh") extAuthPath = filepath.Join(homeBasePath, "extauth.sh") + beforeLoginPath = filepath.Join(homeBasePath, "beforelogin.sh") err = ioutil.WriteFile(pubKeyPath, []byte(testPubKey+"\n"), 0600) if err != nil { logger.WarnToConsole("unable to save public key to file: %v", err) @@ -210,6 +212,7 @@ func TestMain(m *testing.M) { os.Remove(privateKeyPath) os.Remove(gitWrapPath) os.Remove(extAuthPath) + os.Remove(beforeLoginPath) os.Remove(keyIntAuthPath) os.Exit(exitCode) } @@ -1266,6 +1269,20 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) { t.Errorf("unable to read remote dir: %v", err) } } + user.Status = 0 + user, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("error updating user: %v", err) + } + client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) + if err == nil { + t.Error("keyboard interactive auth must fail the user is disabled") + } + user.Status = 1 + user, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("error updating user: %v", err) + } ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, -1), 0755) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) if err == nil { @@ -1288,6 +1305,68 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) { os.RemoveAll(user.GetHomeDir()) } +func TestBeforeLoginScript(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("this test is not available on Windows") + } + usePubKey := true + u := getTestUser(usePubKey) + dataProvider := dataprovider.GetProvider() + dataprovider.Close(dataProvider) + config.LoadConfig(configDir, "") + providerConf := config.GetProviderConf() + ioutil.WriteFile(beforeLoginPath, getBeforeLoginScriptContent(u, false), 0755) + providerConf.BeforeLoginProgram = beforeLoginPath + err := dataprovider.Initialize(providerConf, configDir) + if err != nil { + t.Errorf("error initializing data provider") + } + httpd.SetDataProvider(dataprovider.GetProvider()) + sftpd.SetDataProvider(dataprovider.GetProvider()) + + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + client, err := getSftpClient(u, 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("unable to get working dir: %v", err) + } + } + ioutil.WriteFile(beforeLoginPath, getBeforeLoginScriptContent(user, true), 0755) + _, err = getSftpClient(u, usePubKey) + if err == nil { + t.Error("before login script returned a non json response, login must fail") + } + user.Status = 0 + ioutil.WriteFile(beforeLoginPath, getBeforeLoginScriptContent(user, false), 0755) + _, err = getSftpClient(u, usePubKey) + if err == nil { + t.Error("before login script returned a disabled user, login must fail") + } + _, err = httpd.RemoveUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to remove user: %v", err) + } + os.RemoveAll(user.GetHomeDir()) + dataProvider = dataprovider.GetProvider() + dataprovider.Close(dataProvider) + config.LoadConfig(configDir, "") + providerConf = config.GetProviderConf() + err = dataprovider.Initialize(providerConf, configDir) + if err != nil { + t.Errorf("error initializing data provider") + } + httpd.SetDataProvider(dataprovider.GetProvider()) + sftpd.SetDataProvider(dataprovider.GetProvider()) + os.Remove(beforeLoginPath) +} + func TestLoginExternalAuthPwdAndPubKey(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("this test is not available on Windows") @@ -4958,6 +5037,19 @@ func getExtAuthScriptContent(user dataprovider.User, sleepTime int, nonJsonRespo return extAuthContent } +func getBeforeLoginScriptContent(user dataprovider.User, nonJsonResponse bool) []byte { + content := []byte("#!/bin/sh\n\n") + if nonJsonResponse { + content = append(content, []byte("echo 'text response'\n")...) + return content + } + if len(user.Username) > 0 { + u, _ := json.Marshal(user) + content = append(content, []byte(fmt.Sprintf("echo '%v'\n", string(u)))...) + } + return content +} + func printLatestLogs(maxNumberOfLines int) { var lines []string f, err := os.Open(logFilePath) diff --git a/sftpgo.json b/sftpgo.json index 4c1cac50..1664ce1d 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -43,7 +43,8 @@ }, "external_auth_program": "", "external_auth_scope": 0, - "credentials_path": "credentials" + "credentials_path": "credentials", + "before_login_program": "" }, "httpd": { "bind_port": 8080,