add post-login hook

a login scope is supported too so you can get notifications for failed logins,
successful logins or both
This commit is contained in:
Nicola Murino 2020-08-12 16:15:12 +02:00
parent a9e21c282a
commit aa0ed5dbd0
21 changed files with 220 additions and 129 deletions

View file

@ -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)
}

View file

@ -115,6 +115,8 @@ func init() {
ExternalAuthScope: 0,
CredentialsPath: "credentials",
PreLoginHook: "",
PostLoginHook: "",
PostLoginScope: 0,
},
HTTPDConfig: httpd.Conf{
BindPort: 8080,

View file

@ -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 "<http_url>?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)
}

View file

@ -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)

View file

@ -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.

View file

@ -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 `<http_url>?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 `<http_url>?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.

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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:

36
docs/post-login-hook.md Normal file
View file

@ -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

View file

@ -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)
}

View file

@ -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"},

View file

@ -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{}

View file

@ -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("")
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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) {

View file

@ -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,

View file

@ -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)
}

View file

@ -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()