Add client IP address to external auth, pre-login and keyboard interactive hooks

This commit is contained in:
Nicola Murino 2020-08-04 18:03:28 +02:00
parent fa41bfd06a
commit 91dcc349de
10 changed files with 59 additions and 40 deletions

View file

@ -94,7 +94,7 @@ var (
QuotaScans ActiveScans
idleTimeoutTicker *time.Ticker
idleTimeoutTickerDone chan bool
supportedProcols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP}
supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP}
)
// Initialize sets the common configuration

View file

@ -37,7 +37,7 @@ type BaseConnection struct {
// NewBaseConnection returns a new BaseConnection
func NewBaseConnection(ID, protocol string, user dataprovider.User, fs vfs.Fs) *BaseConnection {
connID := ID
if utils.IsStringInSlice(protocol, supportedProcols) {
if utils.IsStringInSlice(protocol, supportedProtocols) {
connID = fmt.Sprintf("%v_%v", protocol, ID)
}
return &BaseConnection{
@ -79,7 +79,7 @@ func (c *BaseConnection) GetProtocol() string {
// SetProtocol sets the protocol for this connection
func (c *BaseConnection) SetProtocol(protocol string) {
c.protocol = protocol
if utils.IsStringInSlice(c.protocol, supportedProcols) {
if utils.IsStringInSlice(c.protocol, supportedProtocols) {
c.ID = fmt.Sprintf("%v_%v", c.protocol, c.ID)
}
}

View file

@ -1030,7 +1030,7 @@ func TestUpdateQuotaMoveVFolders(t *testing.T) {
func TestErrorsMapping(t *testing.T) {
fs := vfs.NewOsFs("", os.TempDir(), nil)
conn := NewBaseConnection("", ProtocolSFTP, dataprovider.User{}, fs)
for _, protocol := range supportedProcols {
for _, protocol := range supportedProtocols {
conn.SetProtocol(protocol)
err := conn.GetFsError(os.ErrNotExist)
if protocol == ProtocolSFTP {

View file

@ -177,6 +177,7 @@ type Config struct {
// 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
@ -190,6 +191,7 @@ type Config struct {
// 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
@ -227,15 +229,18 @@ type Config struct {
//
// - 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 is added to the query string, for example "<http_url>?login_method=password".
// 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.
// 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:
@ -262,6 +267,7 @@ type BackupData struct {
type keyboardAuthHookRequest struct {
RequestID string `json:"request_id"`
Username string `json:"username,omitempty"`
IP string `json:"ip,omitempty"`
Password string `json:"password,omitempty"`
Answers []string `json:"answers,omitempty"`
Questions []string `json:"questions,omitempty"`
@ -432,16 +438,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 string, password string) (User, error) {
func CheckUserAndPass(username, password, ip string) (User, error) {
if len(config.ExternalAuthHook) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
user, err := doExternalAuth(username, password, nil, "")
user, err := doExternalAuth(username, password, nil, "", ip)
if err != nil {
return user, err
}
return checkUserAndPass(user, password)
}
if len(config.PreLoginHook) > 0 {
user, err := executePreLoginHook(username, SSHLoginMethodPassword)
user, err := executePreLoginHook(username, SSHLoginMethodPassword, ip)
if err != nil {
return user, err
}
@ -451,16 +457,16 @@ func CheckUserAndPass(username string, password 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) (User, string, error) {
func CheckUserAndPubKey(username string, pubKey []byte, ip string) (User, string, error) {
if len(config.ExternalAuthHook) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&2 != 0) {
user, err := doExternalAuth(username, "", pubKey, "")
user, err := doExternalAuth(username, "", pubKey, "", ip)
if err != nil {
return user, "", err
}
return checkUserAndPubKey(user, pubKey)
}
if len(config.PreLoginHook) > 0 {
user, err := executePreLoginHook(username, SSHLoginMethodPublicKey)
user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip)
if err != nil {
return user, "", err
}
@ -471,20 +477,20 @@ func CheckUserAndPubKey(username string, pubKey []byte) (User, string, error) {
// CheckKeyboardInteractiveAuth checks the keyboard interactive authentication and returns
// the authenticated user or an error
func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.KeyboardInteractiveChallenge) (User, error) {
func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.KeyboardInteractiveChallenge, ip 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")
user, err = doExternalAuth(username, "", nil, "1", ip)
} else if len(config.PreLoginHook) > 0 {
user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive)
user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip)
} else {
user, err = provider.userExists(username)
}
if err != nil {
return user, err
}
return doKeyboardInteractiveAuth(user, authHook, client)
return doKeyboardInteractiveAuth(user, authHook, client, ip)
}
// UpdateLastLogin updates the last login fields for the given SFTP user
@ -1303,7 +1309,7 @@ func sendKeyboardAuthHTTPReq(url *url.URL, request keyboardAuthHookRequest) (key
return response, err
}
func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.KeyboardInteractiveChallenge) (int, error) {
func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (int, error) {
authResult := 0
var url *url.URL
url, err := url.Parse(authHook)
@ -1314,6 +1320,7 @@ func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.K
requestID := xid.New().String()
req := keyboardAuthHookRequest{
Username: user.Username,
IP: ip,
Password: user.Password,
RequestID: requestID,
}
@ -1388,13 +1395,14 @@ func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge,
return nil
}
func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.KeyboardInteractiveChallenge) (int, error) {
func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (int, error) {
authResult := 0
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, authHook)
cmd.Env = append(os.Environ(),
fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", user.Username),
fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip),
fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", user.Password))
stdout, err := cmd.StdoutPipe()
if err != nil {
@ -1445,13 +1453,13 @@ func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.Ke
return authResult, err
}
func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardInteractiveChallenge) (User, error) {
func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (User, error) {
var authResult int
var err error
if strings.HasPrefix(authHook, "http") {
authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client)
authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip)
} else {
authResult, err = executeKeyboardInteractiveProgram(user, authHook, client)
authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip)
}
if err != nil {
return user, err
@ -1466,7 +1474,7 @@ func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardIn
return user, nil
}
func getPreLoginHookResponse(loginMethod string, userAsJSON []byte) ([]byte, error) {
func getPreLoginHookResponse(loginMethod, ip string, userAsJSON []byte) ([]byte, error) {
if strings.HasPrefix(config.PreLoginHook, "http") {
var url *url.URL
var result []byte
@ -1477,6 +1485,7 @@ func getPreLoginHookResponse(loginMethod string, userAsJSON []byte) ([]byte, err
}
q := url.Query()
q.Add("login_method", loginMethod)
q.Add("ip", ip)
url.RawQuery = q.Encode()
httpClient := httpclient.GetHTTPClient()
resp, err := httpClient.Post(url.String(), "application/json", bytes.NewBuffer(userAsJSON))
@ -1499,11 +1508,12 @@ func getPreLoginHookResponse(loginMethod string, userAsJSON []byte) ([]byte, err
cmd.Env = append(os.Environ(),
fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)),
fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod),
fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip),
)
return cmd.Output()
}
func executePreLoginHook(username, loginMethod string) (User, error) {
func executePreLoginHook(username, loginMethod, ip string) (User, error) {
u, err := provider.userExists(username)
if err != nil {
if _, ok := err.(*RecordNotFoundError); !ok {
@ -1518,7 +1528,7 @@ func executePreLoginHook(username, loginMethod string) (User, error) {
if err != nil {
return u, err
}
out, err := getPreLoginHookResponse(loginMethod, userAsJSON)
out, err := getPreLoginHookResponse(loginMethod, ip, userAsJSON)
if err != nil {
return u, fmt.Errorf("Pre-login hook error: %v", err)
}
@ -1557,7 +1567,7 @@ func executePreLoginHook(username, loginMethod string) (User, error) {
return provider.userExists(username)
}
func getExternalAuthResponse(username, password, pkey, keyboardInteractive string) ([]byte, error) {
func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip string) ([]byte, error) {
if strings.HasPrefix(config.ExternalAuthHook, "http") {
var url *url.URL
var result []byte
@ -1569,6 +1579,7 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive strin
httpClient := httpclient.GetHTTPClient()
authRequest := make(map[string]string)
authRequest["username"] = username
authRequest["ip"] = ip
authRequest["password"] = password
authRequest["public_key"] = pkey
authRequest["keyboard_interactive"] = keyboardInteractive
@ -1593,13 +1604,14 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive strin
cmd := exec.CommandContext(ctx, config.ExternalAuthHook)
cmd.Env = append(os.Environ(),
fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username),
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_KEYBOARD_INTERACTIVE=%v", keyboardInteractive))
return cmd.Output()
}
func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive string) (User, error) {
func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip string) (User, error) {
var user User
pkey := ""
if len(pubKey) > 0 {
@ -1609,7 +1621,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
}
pkey = string(ssh.MarshalAuthorizedKey(k))
}
out, err := getExternalAuthResponse(username, password, pkey, keyboardInteractive)
out, err := getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip)
if err != nil {
return user, fmt.Errorf("External auth error: %v", err)
}

View file

@ -12,11 +12,11 @@ RUN go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -ldfl
# now define the run environment
FROM debian:latest
# ca-certificates is needed for Cloud Storage Support and to expose the REST API over HTTPS.
RUN apt-get update && apt-get install -y ca-certificates
# ca-certificates is needed for Cloud Storage Support and for HTTPS/FTPS.
RUN apt-get update && apt-get install -y ca-certificates && apt-get clean
# git and rsync are optional, uncomment the next line to add support for them if needed.
#RUN apt-get update && apt-get install -y git rsync
#RUN apt-get update && apt-get install -y git rsync && apt-get clean
ARG BASE_DIR=/app
ARG DATA_REL_DIR=data
@ -82,6 +82,5 @@ ENV SFTPGO_HTTPD__BACKUPS_PATH=${BACKUPS_DIR}
#ENV SFTPGO_FTPD__CERTIFICATE_FILE=${CONFIG_DIR}/mycert.crt
#ENV SFTPGO_FTPD__CERTIFICATE_KEY_FILE=${CONFIG_DIR}/mycert.key
ENTRYPOINT ["sftpgo"]
CMD ["serve"]

View file

@ -7,13 +7,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
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 is added to the query string, for example `<http_url>?login_method=password`.
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.
Actions defined for user's updates will not be executed in this case.

View file

@ -5,6 +5,7 @@ To enable external authentication, you must set the absolute path of your authen
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
@ -15,6 +16,7 @@ The program must write, on its standard output, a valid SFTPGo user serialized a
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

View file

@ -9,6 +9,7 @@ To enable keyboard interactive authentication, you must set the absolute path of
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`, this is the hashed password as stored inside the data provider
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.
@ -77,6 +78,7 @@ The request body will contain a JSON struct with the following fields:
- `request_id`, string. Unique request identifier
- `username`, string
- `ip`, string
- `password`, string. This is the hashed password as stored inside the data provider
- `answers`, list of string. It will be null for the first request
- `questions`, list of string. It will contains the previous asked questions. It will be null for the first request
@ -93,7 +95,7 @@ Content-Length: 189
Content-Type: application/json
Accept-Encoding: gzip
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA=="}
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA=="}
```
as you can see in this first requests `answers` and `questions` are null.
@ -121,7 +123,7 @@ Content-Length: 233
Content-Type: application/json
Accept-Encoding: gzip
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["OK"],"questions":["Password: "]}
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["OK"],"questions":["Password: "]}
```
Here is the HTTP response that istructs SFTPGo to ask for a new question:
@ -147,7 +149,7 @@ Content-Length: 239
Content-Type: application/json
Accept-Encoding: gzip
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["answer2"],"questions":["Question2: "]}
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["answer2"],"questions":["Question2: "]}
```
Here is the final HTTP response that allows the user login:

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)
user, err := dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(remoteAddr))
if err != nil {
updateLoginMetrics(username, remoteAddr, dataprovider.FTPLoginMethodPassword, err)
return nil, err

View file

@ -595,7 +595,8 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe
}
certPerm = &cert.Permissions
}
if user, keyID, err = dataprovider.CheckUserAndPubKey(conn.User(), pubKey.Marshal()); err == nil {
ipAddr := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String())
if user, keyID, err = dataprovider.CheckUserAndPubKey(conn.User(), pubKey.Marshal(), ipAddr); err == nil {
if user.IsPartialAuth(method) {
logger.Debug(logSender, connectionID, "user %#v authenticated with partial success", conn.User())
return certPerm, ssh.ErrPartialSuccess
@ -625,7 +626,8 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [
if len(conn.PartialSuccessMethods()) == 1 {
method = dataprovider.SSHLoginMethodKeyAndPassword
}
if user, err = dataprovider.CheckUserAndPass(conn.User(), string(pass)); err == nil {
ipAddr := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String())
if user, err = dataprovider.CheckUserAndPass(conn.User(), string(pass), ipAddr); err == nil {
sshPerm, err = loginUser(user, method, "", conn)
}
updateLoginMetrics(conn, method, err)
@ -641,7 +643,8 @@ func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetad
if len(conn.PartialSuccessMethods()) == 1 {
method = dataprovider.SSHLoginMethodKeyAndKeyboardInt
}
if user, err = dataprovider.CheckKeyboardInteractiveAuth(conn.User(), c.KeyboardInteractiveHook, client); err == nil {
ipAddr := utils.GetIPFromRemoteAddress(conn.RemoteAddr().String())
if user, err = dataprovider.CheckKeyboardInteractiveAuth(conn.User(), c.KeyboardInteractiveHook, client, ipAddr); err == nil {
sshPerm, err = loginUser(user, method, "", conn)
}
updateLoginMetrics(conn, method, err)