diff --git a/common/common.go b/common/common.go index 0a2b0012..de36ac44 100644 --- a/common/common.go +++ b/common/common.go @@ -17,6 +17,7 @@ import ( "github.com/pires/go-proxyproto" + "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/httpclient" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/metrics" @@ -446,9 +447,11 @@ func (conns *ActiveConnections) checkIdleConnections() { logger.Debug(conn.GetProtocol(), conn.GetID(), "close idle connection, idle time: %v, username: %#v close err: %v", idleTime, conn.GetUsername(), err) if isFTPNoAuth { - logger.ConnectionFailedLog("", utils.GetIPFromRemoteAddress(c.GetRemoteAddress()), - "no_auth_tryed", "client idle") + ip := utils.GetIPFromRemoteAddress(c.GetRemoteAddress()) + logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTryed, c.GetProtocol(), "client idle") metrics.AddNoAuthTryed() + dataprovider.ExecutePostLoginHook("", dataprovider.LoginMethodNoAuthTryed, ip, c.GetProtocol(), + dataprovider.ErrNoAuthTryed) } }(c, isUnauthenticatedFTPUser) } diff --git a/config/config.go b/config/config.go index 3ccd99e7..ce599fd0 100644 --- a/config/config.go +++ b/config/config.go @@ -115,6 +115,8 @@ func init() { ExternalAuthScope: 0, CredentialsPath: "credentials", PreLoginHook: "", + PostLoginHook: "", + PostLoginScope: 0, }, HTTPDConfig: httpd.Conf{ BindPort: 8080, diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index fdacf00b..941a748c 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -88,14 +88,16 @@ var ( ValidPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermOverwrite, PermRename, PermDelete, PermCreateDirs, PermCreateSymlinks, PermChmod, PermChown, PermChtimes} // ValidSSHLoginMethods defines all the valid SSH login methods - ValidSSHLoginMethods = []string{SSHLoginMethodPublicKey, SSHLoginMethodPassword, SSHLoginMethodKeyboardInteractive, + ValidSSHLoginMethods = []string{SSHLoginMethodPublicKey, LoginMethodPassword, SSHLoginMethodKeyboardInteractive, SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt} // SSHMultiStepsLoginMethods defines the supported Multi-Step Authentications SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt} - config Config - provider Provider - sqlPlaceholders []string - hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, + // ErrNoAuthTryed defines the error for connection closed before authentication + ErrNoAuthTryed = errors.New("no auth tryed") + config Config + provider Provider + sqlPlaceholders []string + hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix} pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix} pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix} @@ -173,39 +175,8 @@ type Config struct { Actions UserActions `json:"actions" mapstructure:"actions"` // Absolute path to an external program or an HTTP URL to invoke for users authentication. // Leave empty to use builtin authentication. - // The external program can read the following environment variables to get info about the user trying - // to authenticate: - // - // - SFTPGO_AUTHD_USERNAME - // - SFTPGO_AUTHD_IP - // - SFTPGO_AUTHD_PASSWORD, not empty for password authentication - // - SFTPGO_AUTHD_PUBLIC_KEY, not empty for public key authentication - // - SFTPGO_AUTHD_KEYBOARD_INTERACTIVE, not empty for keyboard interactive authentication - // - // 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 a user with an empty username if the authentication fails. - // If the hook is an HTTP URL then it will be invoked as HTTP POST. - // The request body will contain a JSON serialized struct with the following fields: - // - // - username - // - ip - // - password, not empty for password authentication - // - public_key, not empty for public key authentication - // - keyboard_interactive, not empty for keyboard interactive authentication - // - // If authentication succeed the HTTP response code must be 200 and the response body a valid SFTPGo user - // serialized as JSON. If the authentication fails the HTTP response code must be != 200 or the response body - // must be empty. - // // 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 hook 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 30 seconds. // This method is slower than built-in authentication methods, but it's very flexible as anyone can // easily write his own authentication hooks. ExternalAuthHook string `json:"external_auth_hook" mapstructure:"external_auth_hook"` @@ -225,28 +196,6 @@ type Config struct { // Absolute path to an external program or an HTTP URL to invoke just before the user login. // This program/URL allows to modify or create the user trying to login. // It is useful if you have users with dynamic fields to update 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, possible values are: "password", "publickey" and "keyboard-interactive" - // - SFTPGO_LOGIND_IP, ip address of the user trying to login - // - // The program must write on its standard output an empty string if no user update is needed - // or a valid SFTPGo user serialized as JSON. - // - // If the hook is an HTTP URL then it will be invoked as HTTP POST. - // The login method and the ip address of the user trying to login are added to - // the query string, for example "?login_method=password&ip=1.2.3.4" - // The request body will contain the user trying to login serialized as JSON. - // If no modification is needed the HTTP response code must be 204, otherwise - // the response code must be 200 and the response body a valid SFTPGo user - // serialized as JSON. - // - // The JSON response can include only the fields to update instead of the full user, - // for example if you want to disable the user you can return a response like this: - // - // {"status":0} - // // Please note that if you want to create a new user, the pre-login hook response must // include all the mandatory user fields. // @@ -256,6 +205,15 @@ type Config struct { // PreLoginHook and ExternalAuthHook are mutally exclusive. // Leave empty to disable. PreLoginHook string `json:"pre_login_hook" mapstructure:"pre_login_hook"` + // Absolute path to an external program or an HTTP URL to invoke after the user login. + // Based on the configured scope you can choose if notify failed or successful logins + // or both + PostLoginHook string `json:"post_login_hook" mapstructure:"post_login_hook"` + // PostLoginScope defines the scope for the post-login hook. + // - 0 means notify both failed and successful logins + // - 1 means notify failed logins + // - 2 means notify successful logins + PostLoginScope int `json:"post_login_scope" mapstructure:"post_login_scope"` } // BackupData defines the structure for the backup/restore files @@ -403,6 +361,16 @@ func validateHooks() error { return err } } + if len(config.PostLoginHook) > 0 && !strings.HasPrefix(config.PostLoginHook, "http") { + if !filepath.IsAbs(config.PostLoginHook) { + return fmt.Errorf("invalid post-login hook: %#v must be an absolute path", config.PostLoginHook) + } + _, err := os.Stat(config.PostLoginHook) + if err != nil { + providerLog(logger.LevelWarn, "invalid post-login hook: %v", err) + return err + } + } return nil } @@ -438,16 +406,16 @@ func InitializeDatabase(cnf Config, basePath string) error { } // CheckUserAndPass retrieves the SFTP user with the given username and password if a match is found or an error -func CheckUserAndPass(username, password, ip string) (User, error) { +func CheckUserAndPass(username, password, ip, protocol string) (User, error) { if len(config.ExternalAuthHook) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) { - user, err := doExternalAuth(username, password, nil, "", ip) + user, err := doExternalAuth(username, password, nil, "", ip, protocol) if err != nil { return user, err } return checkUserAndPass(user, password) } if len(config.PreLoginHook) > 0 { - user, err := executePreLoginHook(username, SSHLoginMethodPassword, ip) + user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol) if err != nil { return user, err } @@ -457,16 +425,16 @@ func CheckUserAndPass(username, password, ip string) (User, error) { } // CheckUserAndPubKey retrieves the SFTP user with the given username and public key if a match is found or an error -func CheckUserAndPubKey(username string, pubKey []byte, ip string) (User, string, error) { +func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string) (User, string, error) { if len(config.ExternalAuthHook) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&2 != 0) { - user, err := doExternalAuth(username, "", pubKey, "", ip) + user, err := doExternalAuth(username, "", pubKey, "", ip, protocol) if err != nil { return user, "", err } return checkUserAndPubKey(user, pubKey) } if len(config.PreLoginHook) > 0 { - user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip) + user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip, protocol) if err != nil { return user, "", err } @@ -477,13 +445,13 @@ func CheckUserAndPubKey(username string, pubKey []byte, ip string) (User, string // CheckKeyboardInteractiveAuth checks the keyboard interactive authentication and returns // the authenticated user or an error -func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (User, error) { +func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (User, error) { var user User var err error if len(config.ExternalAuthHook) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) { - user, err = doExternalAuth(username, "", nil, "1", ip) + user, err = doExternalAuth(username, "", nil, "1", ip, protocol) } else if len(config.PreLoginHook) > 0 { - user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip) + user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol) } else { user, err = provider.userExists(username) } @@ -1474,7 +1442,7 @@ func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardIn return user, nil } -func getPreLoginHookResponse(loginMethod, ip string, userAsJSON []byte) ([]byte, error) { +func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte) ([]byte, error) { if strings.HasPrefix(config.PreLoginHook, "http") { var url *url.URL var result []byte @@ -1486,6 +1454,7 @@ func getPreLoginHookResponse(loginMethod, ip string, userAsJSON []byte) ([]byte, q := url.Query() q.Add("login_method", loginMethod) q.Add("ip", ip) + q.Add("protocol", protocol) url.RawQuery = q.Encode() httpClient := httpclient.GetHTTPClient() resp, err := httpClient.Post(url.String(), "application/json", bytes.NewBuffer(userAsJSON)) @@ -1509,11 +1478,12 @@ func getPreLoginHookResponse(loginMethod, ip string, userAsJSON []byte) ([]byte, fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)), fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod), fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip), + fmt.Sprintf("SFTPGO_LOGIND_PROTOCOL=%v", ip), ) return cmd.Output() } -func executePreLoginHook(username, loginMethod, ip string) (User, error) { +func executePreLoginHook(username, loginMethod, ip, protocol string) (User, error) { u, err := provider.userExists(username) if err != nil { if _, ok := err.(*RecordNotFoundError); !ok { @@ -1528,7 +1498,7 @@ func executePreLoginHook(username, loginMethod, ip string) (User, error) { if err != nil { return u, err } - out, err := getPreLoginHookResponse(loginMethod, ip, userAsJSON) + out, err := getPreLoginHookResponse(loginMethod, ip, protocol, userAsJSON) if err != nil { return u, fmt.Errorf("Pre-login hook error: %v", err) } @@ -1567,7 +1537,70 @@ func executePreLoginHook(username, loginMethod, ip string) (User, error) { return provider.userExists(username) } -func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip string) ([]byte, error) { +// ExecutePostLoginHook executes the post login hook if defined +func ExecutePostLoginHook(username, loginMethod, ip, protocol string, err error) { + if len(config.PostLoginHook) == 0 { + return + } + if config.PostLoginScope == 1 && err == nil { + return + } + if config.PostLoginScope == 2 && err != nil { + return + } + + go func(username, loginMethod, ip, protocol string, err error) { + status := 0 + if err == nil { + status = 1 + } + if strings.HasPrefix(config.PostLoginHook, "http") { + var url *url.URL + url, err := url.Parse(config.PostLoginHook) + if err != nil { + providerLog(logger.LevelDebug, "Invalid post-login hook %#v", config.PostLoginHook) + return + } + postReq := make(map[string]interface{}) + postReq["username"] = username + postReq["login_method"] = loginMethod + postReq["ip"] = ip + postReq["protocol"] = protocol + postReq["status"] = status + + postAsJSON, err := json.Marshal(postReq) + if err != nil { + providerLog(logger.LevelWarn, "error serializing post login request: %v", err) + return + } + startTime := time.Now() + respCode := 0 + httpClient := httpclient.GetHTTPClient() + resp, err := httpClient.Post(url.String(), "application/json", bytes.NewBuffer(postAsJSON)) + if err == nil { + respCode = resp.StatusCode + resp.Body.Close() + } + providerLog(logger.LevelDebug, "post login hook executed, response code: %v, elapsed: %v err: %v", + respCode, time.Since(startTime), err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, config.PostLoginHook) + cmd.Env = append(os.Environ(), + fmt.Sprintf("SFTPGO_LOGIND_USER=%v", username), + fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip), + fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod), + fmt.Sprintf("SFTPGO_LOGIND_STATUS=%v", status), + fmt.Sprintf("SFTPGO_LOGIND_PROTOCOL=%v", protocol)) + startTime := time.Now() + err = cmd.Run() + providerLog(logger.LevelDebug, "post login hook executed, elapsed %v err: %v", time.Since(startTime), err) + }(username, loginMethod, ip, protocol, err) +} + +func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, protocol string) ([]byte, error) { if strings.HasPrefix(config.ExternalAuthHook, "http") { var url *url.URL var result []byte @@ -1582,6 +1615,7 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip s authRequest["ip"] = ip authRequest["password"] = password authRequest["public_key"] = pkey + authRequest["protocol"] = protocol authRequest["keyboard_interactive"] = keyboardInteractive authRequestAsJSON, err := json.Marshal(authRequest) if err != nil { @@ -1607,11 +1641,12 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip s fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip), fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password), fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%v", pkey), + fmt.Sprintf("SFTPGO_AUTHD_PROTOCOL=%v", protocol), fmt.Sprintf("SFTPGO_AUTHD_KEYBOARD_INTERACTIVE=%v", keyboardInteractive)) return cmd.Output() } -func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip string) (User, error) { +func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip, protocol string) (User, error) { var user User pkey := "" if len(pubKey) > 0 { @@ -1621,7 +1656,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv } pkey = string(ssh.MarshalAuthorizedKey(k)) } - out, err := getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip) + out, err := getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, protocol) if err != nil { return user, fmt.Errorf("External auth error: %v", err) } diff --git a/dataprovider/user.go b/dataprovider/user.go index 7315d157..5795f3f9 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -48,13 +48,12 @@ const ( // Available login methods const ( + LoginMethodNoAuthTryed = "no_auth_tryed" + LoginMethodPassword = "password" SSHLoginMethodPublicKey = "publickey" - SSHLoginMethodPassword = "password" SSHLoginMethodKeyboardInteractive = "keyboard-interactive" SSHLoginMethodKeyAndPassword = "publickey+password" SSHLoginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" - FTPLoginMethodPassword = "ftp-password" - WebDavLoginMethodPassword = "dav-password" ) var ( @@ -371,7 +370,7 @@ func (u *User) GetNextAuthMethods(partialSuccessMethods []string) []string { } for _, method := range u.GetAllowedLoginMethods() { if method == SSHLoginMethodKeyAndPassword { - methods = append(methods, SSHLoginMethodPassword) + methods = append(methods, LoginMethodPassword) } if method == SSHLoginMethodKeyAndKeyboardInt { methods = append(methods, SSHLoginMethodKeyboardInteractive) diff --git a/docs/custom-actions.md b/docs/custom-actions.md index 5ca9cf6b..981db685 100644 --- a/docs/custom-actions.md +++ b/docs/custom-actions.md @@ -27,6 +27,7 @@ The external program can also read the following environment variables: - `SFTPGO_ACTION_BUCKET`, non-empty for S3 and GCS backends - `SFTPGO_ACTION_ENDPOINT`, non-empty for S3 backend if configured - `SFTPGO_ACTION_STATUS`, integer. 0 means a generic error occurred. 1 means no error, 2 means quota exceeded error +- `SFTPGO_ACTION_PROTOCOL`, string. Possible values are `SSH`, `SFTP`, `SCP`, `FTP`, `DAV` Previous global environment variables aren't cleared when the script is called. The program must finish within 30 seconds. @@ -43,6 +44,7 @@ If the `hook` defines an HTTP URL then this URL will be invoked as HTTP POST. Th - `bucket`, not null for S3 and GCS backends - `endpoint`, not null for S3 backend if configured - `status`, integer. 0 means a generic error occurred. 1 means no error, 2 means quota exceeded error +- `protocol`, string. Possible values are `SSH`, `FTP`, `DAV` The HTTP request will use the global configuration for HTTP clients. diff --git a/docs/dynamic-user-mod.md b/docs/dynamic-user-mod.md index be7b02d6..66187741 100644 --- a/docs/dynamic-user-mod.md +++ b/docs/dynamic-user-mod.md @@ -8,13 +8,14 @@ The external program can read the following environment variables to get info ab - `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON. A JSON serialized user id equal to zero means the user does not exists inside SFTPGo - `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey` and `keyboard-interactive` - `SFTPGO_LOGIND_IP`, ip address of the user trying to login +- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` The program must write, on its the standard output: - an empty string (or no response at all) if the user should not be created/updated - or the SFTPGo user, JSON serialized, if you want create or update the given user -If the hook is an HTTP URL then it will be invoked as HTTP POST. The login method and the ip address of the user trying to login are added to the query string, for example `?login_method=password&ip=1.2.3.4`. +If the hook is an HTTP URL then it will be invoked as HTTP POST. The login method, the used protocol and the ip address of the user trying to login are added to the query string, for example `?login_method=password&ip=1.2.3.4&protocol=SSH`. The request body will contain the user trying to login serialized as JSON. If no modification is needed the HTTP response code must be 204, otherwise the response code must be 200 and the response body a valid SFTPGo user serialized as JSON. Actions defined for user's updates will not be executed in this case. diff --git a/docs/external-auth.md b/docs/external-auth.md index cc1b3b57..e146df92 100644 --- a/docs/external-auth.md +++ b/docs/external-auth.md @@ -6,6 +6,7 @@ The external program can read the following environment variables to get info ab - `SFTPGO_AUTHD_USERNAME` - `SFTPGO_AUTHD_IP` +- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` - `SFTPGO_AUTHD_PASSWORD`, not empty for password authentication - `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication - `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive authentication @@ -17,6 +18,7 @@ If the hook is an HTTP URL then it will be invoked as HTTP POST. The request bod - `username` - `ip` +- `protocol`, possible values are `SSH`, `FTP`, `DAV` - `password`, not empty for password authentication - `public_key`, not empty for public key authentication - `keyboard_interactive`, not empty for keyboard interactive authentication diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 57ba08e4..87be8cff 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -120,6 +120,8 @@ The configuration file contains the following sections: - `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 - `pre_login_program`, string. Deprecated, please use `pre_login_hook`. - `pre_login_hook`, string. Absolute path to an external program or an HTTP URL to invoke to modify user details just before the login. See [Dynamic user modification](./dynamic-user-mod.md) for more details. Leave empty to disable. + - `post_login_hook`, string. Absolute path to an external program or an HTTP URL to invoke to notify a successul or failed login. See [Post-login hook](./post-login-hook.md) for more details. Leave empty to disable. + - `post_login_scope`, defines the scope for the post-login hook. 0 means notify both failed and successful logins. 1 means notify failed logins. 2 means notify successful logins. - **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface - `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" diff --git a/docs/logs.md b/docs/logs.md index 3b3f75d7..40abb968 100644 --- a/docs/logs.md +++ b/docs/logs.md @@ -50,5 +50,6 @@ 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. + - `protocol` string. Possible values are `SSH`, `FTP`, `DAV` - `login_type` string. Can be `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive` or `no_auth_tryed` - `error` string. Optional error description diff --git a/docs/post-connect-hook.md b/docs/post-connect-hook.md index ffca752b..0669a4a6 100644 --- a/docs/post-connect-hook.md +++ b/docs/post-connect-hook.md @@ -1,9 +1,10 @@ -# Post connect hook +# Post-connect hook -This hook is executed as soon as a new connection is estabilished. It notifies the connection's IP address and protocol. Based on the received response, the connection is accepted or rejected. This way you can implement your own blacklist/whitelist of IP addresses. -Please keep in mind that you can easily configure specialized program such as [Fail2ban](http://www.fail2ban.org/) for brute force protection. +This hook is executed as soon as a new connection is estabilished. It notifies the connection's IP address and protocol. Based on the received response, the connection is accepted or rejected. Combining this hook with the [Post-login hook](./post-login-hook.md) you can implement your own (even for Protocol) blacklist/whitelist of IP addresses. -The `post_connect_hook` can be defined as the absolute path of your program or an HTTP URL. +Please keep in mind that you can easily configure specialized program such as [Fail2ban](http://www.fail2ban.org/) for brute force protection. Executing a hook for each connection can be heavy. + +The `post-connect-hook` can be defined as the absolute path of your program or an HTTP URL. If the hook defines an external program it can reads the following environment variables: diff --git a/docs/post-login-hook.md b/docs/post-login-hook.md new file mode 100644 index 00000000..d8b47abb --- /dev/null +++ b/docs/post-login-hook.md @@ -0,0 +1,36 @@ +# Post-login hook + +This hook is executed after a login or after closing a connection for authentication timeout. Defining an appropriate `post_login_scope` you can get notifications for failed logins, successful logins or both. + +Combining this hook with the [Post-connect hook](./post-connect-hook.md) you can implement your own (even for Protocol) blacklist/whitelist of IP addresses. + +Please keep in mind that you can easily configure specialized program such as [Fail2ban](http://www.fail2ban.org/) for brute force protection. Executing a hook after each login can be heavy. + +The `post-login-hook` can be defined as the absolute path of your program or an HTTP URL. + +If the hook defines an external program it can reads the following environment variables: + +- `SFTPGO_LOGIND_USER`, username, can be empty if the connection is closed for authentication timeout +- `SFTPGO_LOGIND_IP` +- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive` or `no_auth_tryed` +- `SFTPGO_LOGIND_STATUS`, 1 means login OK, 0 login KO +- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` + +Previous global environment variables aren't cleared when the script is called. +The program must finish within 20 seconds. + +If the hook is an HTTP URL then it will be invoked as HTTP POST. The request body will contain a JSON serialized struct with the following fields: + +- `username` +- `login_method` +- `ip` +- `protocol` +- `status` + +The HTTP request will use the global configuration for HTTP clients. + +The `post_login_scope` supports the following configuration values: + +- `0` means notify both failed and successful logins +- `1` means notify failed logins. Connections closed for authentication timeout are notified as failed connections. You will get an empty username in this case +- `2` means notify successful logins diff --git a/ftpd/server.go b/ftpd/server.go index f8361743..2c2c9bb3 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -118,7 +118,7 @@ func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) { // AuthUser authenticates the user and selects an handling driver func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) { remoteAddr := cc.RemoteAddr().String() - user, err := dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(remoteAddr)) + user, err := dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(remoteAddr), common.ProtocolFTP) if err != nil { updateLoginMetrics(username, remoteAddr, err) return nil, err @@ -189,10 +189,12 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext } func updateLoginMetrics(username, remoteAddress string, err error) { - metrics.AddLoginAttempt(dataprovider.FTPLoginMethodPassword) + metrics.AddLoginAttempt(dataprovider.LoginMethodPassword) + ip := utils.GetIPFromRemoteAddress(remoteAddress) if err != nil { - logger.ConnectionFailedLog(username, utils.GetIPFromRemoteAddress(remoteAddress), - dataprovider.FTPLoginMethodPassword, err.Error()) + logger.ConnectionFailedLog(username, ip, dataprovider.LoginMethodPassword, + common.ProtocolFTP, err.Error()) } - metrics.AddLoginResult(dataprovider.FTPLoginMethodPassword, err) + metrics.AddLoginResult(dataprovider.LoginMethodPassword, err) + dataprovider.ExecutePostLoginHook(username, dataprovider.LoginMethodPassword, ip, common.ProtocolFTP, err) } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 4eaa1716..2396a88c 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -622,7 +622,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.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} user.Filters.FileExtensions = append(user.Filters.FileExtensions, dataprovider.ExtensionsFilter{ Path: "/subdir", AllowedExtensions: []string{".zip", ".rar"}, diff --git a/httpd/internal_test.go b/httpd/internal_test.go index fe80cca7..59f439bf 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -165,7 +165,7 @@ func TestCompareUserFilters(t *testing.T) { actual.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey} err = checkUser(expected, actual) assert.Error(t, err) - expected.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword} + expected.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} err = checkUser(expected, actual) assert.Error(t, err) expected.Filters.DeniedLoginMethods = []string{} diff --git a/logger/logger.go b/logger/logger.go index b8e563bd..7c705f4a 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -183,13 +183,14 @@ func CommandLog(command, path, target, user, fileMode, connectionID, protocol st // A connection can fail for an authentication error or other errors such as // a client abort or a time out if the login does not happen in two minutes. // These logs are useful for better integration with Fail2ban and similar tools. -func ConnectionFailedLog(user, ip, loginType, errorString string) { +func ConnectionFailedLog(user, ip, loginType, protocol, errorString string) { logger.Debug(). Timestamp(). Str("sender", "connection_failed"). Str("client_ip", ip). Str("username", user). Str("login_type", loginType). + Str("protocol", protocol). Str("error", errorString). Msg("") } diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 1b861de4..d02b8c03 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -350,7 +350,7 @@ func TestUploadFiles(t *testing.T) { func TestWithInvalidHome(t *testing.T) { u := dataprovider.User{} u.HomeDir = "home_rel_path" //nolint:goconst - _, err := loginUser(u, dataprovider.SSHLoginMethodPassword, "", nil) + _, err := loginUser(u, dataprovider.LoginMethodPassword, "", nil) assert.Error(t, err, "login a user with an invalid home_dir must fail") u.HomeDir = os.TempDir() diff --git a/sftpd/server.go b/sftpd/server.go index 190e51e5..48d8f37b 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -272,8 +272,10 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server if err != nil { logger.Debug(logSender, "", "failed to accept an incoming connection: %v", err) if _, ok := err.(*ssh.ServerAuthError); !ok { - logger.ConnectionFailedLog("", utils.GetIPFromRemoteAddress(remoteAddr.String()), "no_auth_tryed", err.Error()) + ip := utils.GetIPFromRemoteAddress(remoteAddr.String()) + logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTryed, common.ProtocolSSH, err.Error()) metrics.AddNoAuthTryed() + dataprovider.ExecutePostLoginHook("", dataprovider.LoginMethodNoAuthTryed, ip, common.ProtocolSSH, err) } return } @@ -596,7 +598,7 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe certPerm = &cert.Permissions } ipAddr := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()) - if user, keyID, err = dataprovider.CheckUserAndPubKey(conn.User(), pubKey.Marshal(), ipAddr); err == nil { + if user, keyID, err = dataprovider.CheckUserAndPubKey(conn.User(), pubKey.Marshal(), ipAddr, common.ProtocolSSH); err == nil { if user.IsPartialAuth(method) { logger.Debug(logSender, connectionID, "user %#v authenticated with partial success", conn.User()) return certPerm, ssh.ErrPartialSuccess @@ -622,12 +624,12 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [ var user dataprovider.User var sshPerm *ssh.Permissions - method := dataprovider.SSHLoginMethodPassword + method := dataprovider.LoginMethodPassword if len(conn.PartialSuccessMethods()) == 1 { method = dataprovider.SSHLoginMethodKeyAndPassword } ipAddr := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()) - if user, err = dataprovider.CheckUserAndPass(conn.User(), string(pass), ipAddr); err == nil { + if user, err = dataprovider.CheckUserAndPass(conn.User(), string(pass), ipAddr, common.ProtocolSSH); err == nil { sshPerm, err = loginUser(user, method, "", conn) } updateLoginMetrics(conn, method, err) @@ -644,7 +646,8 @@ func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetad method = dataprovider.SSHLoginMethodKeyAndKeyboardInt } ipAddr := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()) - if user, err = dataprovider.CheckKeyboardInteractiveAuth(conn.User(), c.KeyboardInteractiveHook, client, ipAddr); err == nil { + if user, err = dataprovider.CheckKeyboardInteractiveAuth(conn.User(), c.KeyboardInteractiveHook, client, + ipAddr, common.ProtocolSSH); err == nil { sshPerm, err = loginUser(user, method, "", conn) } updateLoginMetrics(conn, method, err) @@ -653,8 +656,10 @@ func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetad func updateLoginMetrics(conn ssh.ConnMetadata, method string, err error) { metrics.AddLoginAttempt(method) + ip := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()) if err != nil { - logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) + logger.ConnectionFailedLog(conn.User(), ip, method, common.ProtocolSSH, err.Error()) } metrics.AddLoginResult(method, err) + dataprovider.ExecutePostLoginHook(conn.User(), method, ip, common.ProtocolSSH, err) } diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 2d01a436..516aec34 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -940,7 +940,7 @@ func TestMultiStepLoginKeyAndPwd(t *testing.T) { u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, []string{ dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.SSHLoginMethodPublicKey, - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodKeyboardInteractive, }...) user, _, err := httpd.AddUser(u, http.StatusOK) @@ -984,7 +984,7 @@ func TestMultiStepLoginKeyAndKeyInt(t *testing.T) { u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, []string{ dataprovider.SSHLoginMethodKeyAndPassword, dataprovider.SSHLoginMethodPublicKey, - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodKeyboardInteractive, }...) user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1037,7 +1037,7 @@ func TestMultiStepLoginCertAndPwd(t *testing.T) { u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, []string{ dataprovider.SSHLoginMethodKeyAndKeyboardInt, dataprovider.SSHLoginMethodPublicKey, - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodKeyboardInteractive, }...) user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1159,14 +1159,14 @@ func TestLoginInvalidFs(t *testing.T) { func TestDeniedLoginMethods(t *testing.T) { u := getTestUser(true) - u.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodPassword} + u.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.LoginMethodPassword} user, _, err := httpd.AddUser(u, http.StatusOK) assert.NoError(t, err) client, err := getSftpClient(user, true) if !assert.Error(t, err, "public key login is disabled, authentication must fail") { client.Close() } - user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodPassword} + user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.LoginMethodPassword} user, _, err = httpd.UpdateUser(user, http.StatusOK) assert.NoError(t, err) client, err = getSftpClient(user, true) @@ -5177,7 +5177,7 @@ func TestUserAllowedLoginMethods(t *testing.T) { assert.Equal(t, 0, len(allowedMethods)) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, } @@ -5191,22 +5191,22 @@ func TestUserAllowedLoginMethods(t *testing.T) { func TestUserPartialAuth(t *testing.T) { user := getTestUser(true) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, } - assert.False(t, user.IsPartialAuth(dataprovider.SSHLoginMethodPassword)) + assert.False(t, user.IsPartialAuth(dataprovider.LoginMethodPassword)) assert.False(t, user.IsPartialAuth(dataprovider.SSHLoginMethodKeyboardInteractive)) assert.True(t, user.IsPartialAuth(dataprovider.SSHLoginMethodPublicKey)) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodKeyboardInteractive, } assert.False(t, user.IsPartialAuth(dataprovider.SSHLoginMethodPublicKey)) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, } assert.False(t, user.IsPartialAuth(dataprovider.SSHLoginMethodPublicKey)) @@ -5215,14 +5215,14 @@ func TestUserPartialAuth(t *testing.T) { func TestUserGetNextAuthMethods(t *testing.T) { user := getTestUser(true) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, } methods := user.GetNextAuthMethods(nil) assert.Equal(t, 0, len(methods)) - methods = user.GetNextAuthMethods([]string{dataprovider.SSHLoginMethodPassword}) + methods = user.GetNextAuthMethods([]string{dataprovider.LoginMethodPassword}) assert.Equal(t, 0, len(methods)) methods = user.GetNextAuthMethods([]string{dataprovider.SSHLoginMethodKeyboardInteractive}) @@ -5236,21 +5236,21 @@ func TestUserGetNextAuthMethods(t *testing.T) { methods = user.GetNextAuthMethods([]string{dataprovider.SSHLoginMethodPublicKey}) assert.Equal(t, 2, len(methods)) - assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodPassword, methods)) + assert.True(t, utils.IsStringInSlice(dataprovider.LoginMethodPassword, methods)) assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, methods)) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndKeyboardInt, } methods = user.GetNextAuthMethods([]string{dataprovider.SSHLoginMethodPublicKey}) assert.Equal(t, 1, len(methods)) - assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodPassword, methods)) + assert.True(t, utils.IsStringInSlice(dataprovider.LoginMethodPassword, methods)) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, dataprovider.SSHLoginMethodKeyAndPassword, @@ -5263,19 +5263,19 @@ func TestUserGetNextAuthMethods(t *testing.T) { func TestUserIsLoginMethodAllowed(t *testing.T) { user := getTestUser(true) user.Filters.DeniedLoginMethods = []string{ - dataprovider.SSHLoginMethodPassword, + dataprovider.LoginMethodPassword, dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, } - assert.False(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodPassword, nil)) - assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodPassword, []string{dataprovider.SSHLoginMethodPublicKey})) + assert.False(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil)) + assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, []string{dataprovider.SSHLoginMethodPublicKey})) assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyboardInteractive, []string{dataprovider.SSHLoginMethodPublicKey})) user.Filters.DeniedLoginMethods = []string{ dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyboardInteractive, } - assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodPassword, nil)) + assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil)) } func TestUserEmptySubDirPerms(t *testing.T) { diff --git a/sftpgo.json b/sftpgo.json index 4f4eed3a..9b4abb4a 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -72,7 +72,9 @@ "external_auth_hook": "", "external_auth_scope": 0, "credentials_path": "credentials", - "pre_login_hook": "" + "pre_login_hook": "", + "post_login_hook": "", + "post_login_scope": 0 }, "httpd": { "bind_port": 8080, diff --git a/webdavd/server.go b/webdavd/server.go index b1223b84..7f95eb93 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -141,7 +141,7 @@ func (s *webDavServer) authenticate(r *http.Request) (dataprovider.User, error) if !ok { return user, err401 } - user, err = dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(r.RemoteAddr)) + user, err = dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(r.RemoteAddr), common.ProtocolWebDAV) if err != nil { updateLoginMetrics(username, r.RemoteAddr, err) return user, err @@ -232,10 +232,11 @@ func checkRemoteAddress(r *http.Request) { } func updateLoginMetrics(username, remoteAddress string, err error) { - metrics.AddLoginAttempt(dataprovider.WebDavLoginMethodPassword) + metrics.AddLoginAttempt(dataprovider.LoginMethodPassword) + ip := utils.GetIPFromRemoteAddress(remoteAddress) if err != nil { - logger.ConnectionFailedLog(username, utils.GetIPFromRemoteAddress(remoteAddress), - dataprovider.WebDavLoginMethodPassword, err.Error()) + logger.ConnectionFailedLog(username, ip, dataprovider.LoginMethodPassword, common.ProtocolWebDAV, err.Error()) } - metrics.AddLoginResult(dataprovider.WebDavLoginMethodPassword, err) + metrics.AddLoginResult(dataprovider.LoginMethodPassword, err) + dataprovider.ExecutePostLoginHook(username, dataprovider.LoginMethodPassword, ip, common.ProtocolWebDAV, err) } diff --git a/webdavd/webdavd_test.go b/webdavd/webdavd_test.go index 43eb7ede..fe48e6c3 100644 --- a/webdavd/webdavd_test.go +++ b/webdavd/webdavd_test.go @@ -652,8 +652,6 @@ func TestClientClose(t *testing.T) { }, 1*time.Second, 50*time.Millisecond) for _, stat := range common.Connections.GetStats() { - logger.DebugToConsole("close upload connection id %#v, active transfers: %v", stat.ConnectionID, - stat.GetTransfersAsString()) common.Connections.Close(stat.ConnectionID) } wg.Wait() @@ -684,8 +682,6 @@ func TestClientClose(t *testing.T) { }, 1*time.Second, 50*time.Millisecond) for _, stat := range common.Connections.GetStats() { - logger.DebugToConsole("close download connection id %#v, active transfers: %v", stat.ConnectionID, - stat.GetTransfersAsString()) common.Connections.Close(stat.ConnectionID) } wg.Wait()