mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-25 00:50:31 +00:00
parent
4463421028
commit
9ff303b8c0
7 changed files with 477 additions and 47 deletions
72
README.md
72
README.md
|
@ -9,6 +9,7 @@ Full featured and highly configurable SFTP server
|
|||
- SFTP accounts are virtual accounts stored in a "data provider".
|
||||
- SQLite, MySQL, PostgreSQL, bbolt (key/value store in pure Go) and in memory data providers are supported.
|
||||
- Public key and password authentication. Multiple public keys per user are supported.
|
||||
- Keyboard interactive authentication. You can easily setup a customizable multi factor authentication.
|
||||
- Custom authentication using external programs is supported.
|
||||
- Quota support: accounts can have individual quota expressed as max total size and/or max number of files.
|
||||
- Bandwidth throttling is supported, with distinct settings for upload and download.
|
||||
|
@ -157,6 +158,7 @@ The `sftpgo` configuration file contains the following sections:
|
|||
- `cd`, `pwd`. Some SFTP clients does not support the SFTP SSH_FXP_REALPATH packet type and so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` do nothing and `pwd` always returns the `/` path.
|
||||
- `git-receive-pack`, `git-upload-pack`, `git-upload-archive`. These commands enable support for Git repositories over SSH, they need to be installed and in your system's `PATH`.
|
||||
- `rsync`. The `rsync` command need to be installed and in your system's `PATH`. We cannot avoid that rsync create symlinks so if the user has the permission to create symlinks we add the option `--safe-links` to the received rsync command if it is not already set. This should prevent to create symlinks that point outside the home dir. If the user cannot create symlinks we add the option `--munge-links`, if it is not already set. This should make symlinks unusable (but manually recoverable)
|
||||
- `keyboard_interactive_auth_program`, string. Absolute path to an external program to use for keyboard interactive authentication. See the "Keyboard Interactive Authentication" paragraph for more details.
|
||||
- **"data_provider"**, the configuration for the data provider
|
||||
- `driver`, string. Supported drivers are `sqlite`, `mysql`, `postgresql`, `bolt`, `memory`
|
||||
- `name`, string. Database name. For driver `sqlite` this can be the database name relative to the config dir or the absolute path to the SQLite database.
|
||||
|
@ -179,7 +181,7 @@ The `sftpgo` configuration file contains the following sections:
|
|||
- `command`, string. Absolute path to the command to execute. Leave empty to disable.
|
||||
- `http_notification_url`, a valid URL. Leave empty to disable.
|
||||
- `external_auth_program`, string. Absolute path to an external program to use for users authentication. See the "External Authentication" paragraph for more details.
|
||||
- `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords and public keys). 1 means passwords only. 2 means public keys only
|
||||
- `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive
|
||||
- **"httpd"**, the configuration for the HTTP server used to serve REST API
|
||||
- `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 8080
|
||||
- `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1"
|
||||
|
@ -211,7 +213,8 @@ Here is a full example showing the default config in JSON format:
|
|||
"macs": [],
|
||||
"login_banner_file": "",
|
||||
"setstat_mode": 0,
|
||||
"enabled_ssh_commands": ["md5sum", "sha1sum", "cd", "pwd"]
|
||||
"enabled_ssh_commands": ["md5sum", "sha1sum", "cd", "pwd"],
|
||||
"keyboard_interactive_auth_program": ""
|
||||
},
|
||||
"data_provider": {
|
||||
"driver": "sqlite",
|
||||
|
@ -318,18 +321,22 @@ The external program can read the following environment variables to get info ab
|
|||
- `SFTPGO_AUTHD_USERNAME`
|
||||
- `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
|
||||
|
||||
Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user.
|
||||
The program must respond on the standard output with a valid SFTPGo user serialized as json if the authentication succeed or an user with an empty username if the authentication fails.
|
||||
The program must respond on the standard output with a valid SFTPGo user serialized as JSON if the authentication succeed or an user with an empty username if the authentication fails.
|
||||
If the authentication succeed the user will be automatically added/updated inside the defined data provider. Actions defined for user added/updated will not be executed in this case.
|
||||
The external program should check authentication only, if there are login restrictions such as user disabled, expired, login allowed only from specific IP addresses it is enough to populate the matching user fields and these conditions will be checked in the same way as for built-in users.
|
||||
The external auth program must finish within 15 seconds.
|
||||
This method is slower than built-in authentication, but it's very flexible as anyone can easily write his own authentication program.
|
||||
You can also restrict the authentication scope for the external program using the `external_auth_scope` configuration key:
|
||||
|
||||
- 0 means all supported authetication scopes, the external program will be used for both password and public key authentication
|
||||
- 1 means passwords only, the external program will not be used for public key authentication
|
||||
- 2 means public keys only, the external program will not be used for password authentication
|
||||
- 0 means all supported authetication scopes, the external program will be used for password, public key and keyboard interactive authentication
|
||||
- 1 means passwords only
|
||||
- 2 means public keys only
|
||||
- 4 means keyboard interactive only
|
||||
|
||||
You can combine the scopes, for example 3 means password and public key, 5 password and keyboard interactive and so on.
|
||||
|
||||
Let's see a very basic example. Our sample authentication program will only accept user `test_user` with any password or public key.
|
||||
|
||||
|
@ -345,6 +352,54 @@ fi
|
|||
|
||||
If you have an external authentication program that could be useful for others too, for example LDAP/Active Directory authentication, please let us know and/or send a pull request.
|
||||
|
||||
## Keyboard Interactive Authentication
|
||||
|
||||
Keyboard interactive authentication is in general case a series of question asked by the server with responses provided by the client.
|
||||
This authentication method is typically used for multi factor authentication.
|
||||
There is no restrictions on the number of questions asked on a particular authentication stage; there is also no restrictions on the number of stages involving different sets of questions.
|
||||
|
||||
To enable keyboard interactive authentication you must set the absolute path of your authentication program using `keyboard_interactive_auth_program` key in your configuration file.
|
||||
|
||||
The external program can read the following environment variables to get info about the user trying to authenticate:
|
||||
|
||||
- `SFTPGO_AUTHD_USERNAME`
|
||||
- `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.
|
||||
|
||||
The program must write the questions on its standard output, in a single line, using the following struct JSON serialized:
|
||||
|
||||
- `instruction`, string. A short description to show to the user that is trying to authenticate. Can be empty or omitted
|
||||
- `questions`, list of questions to be asked to the user
|
||||
- `echos` list of boolean flags corresponding to the questions (so the lengths of both lists must be the same) and indicating whether user's reply for a particular question should be echoed on the screen while they are typing: true if it should be echoed, or false if it should be hidden.
|
||||
- `auth_result`, integer. Set this field to 1 to indicate successfull authentication, 0 is ignored, any other value means authentication error. If this fields is found and it is different from 0 then SFTPGo does not read any other questions from the external program and finalize the authentication.
|
||||
|
||||
SFTPGo writes the user answers to the program standard input, one per line, in the same order of the questions.
|
||||
|
||||
Keyboard interactive authentication can be chained to the external authentication.
|
||||
The authentication must finish within 60 seconds.
|
||||
|
||||
Let's see a very basic example. Our sample keyboard interactive authentication program will ask for 2 sets of questions and accept the user if the answer to the last question is `answer3`.
|
||||
|
||||
```
|
||||
#!/bin/sh
|
||||
|
||||
echo '{"questions":["Question1: ","Question2: "],"instruction":"This is a sample for keyboard interactive authentication","echos":[true,false]}'
|
||||
|
||||
read ANSWER1
|
||||
read ANSWER2
|
||||
|
||||
echo '{"questions":["Question3: "],"instruction":"","echos":[true]}'
|
||||
|
||||
read ANSWER3
|
||||
|
||||
if test "$ANSWER3" = "answer3"; then
|
||||
echo '{"auth_result":1}'
|
||||
else
|
||||
echo '{"auth_result":-1}'
|
||||
fi
|
||||
```
|
||||
|
||||
## Custom Actions
|
||||
|
||||
SFTPGo allows to configure custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
|
||||
|
@ -413,7 +468,7 @@ The `command` can also read the following environment variables:
|
|||
Previous global environment variables aren't cleared when the script is called.
|
||||
The `command` must finish within 15 seconds.
|
||||
|
||||
The `http_notification_url`, if defined, will be called invoked as http POST. The action is added to the query string, for example `<http_notification_url>?action=update` and the user is sent serialized as json inside the POST body
|
||||
The `http_notification_url`, if defined, will be called invoked as http POST. The action is added to the query string, for example `<http_notification_url>?action=update` and the user is sent serialized as JSON inside the POST body
|
||||
|
||||
The HTTP request has a 15 seconds timeout.
|
||||
|
||||
|
@ -586,7 +641,7 @@ Several counters and gauges are available, for example:
|
|||
- Total SSH command errors
|
||||
- Number of active connections
|
||||
- Data provider availability
|
||||
- Total successful and failed logins using a password or a public key
|
||||
- Total successful and failed logins using password, public key or keyboard interactive authentication
|
||||
- Total HTTP requests served and totals for response code
|
||||
- Go's runtime details about GC, number of gouroutines and OS threads
|
||||
- Process information like CPU, memory, file descriptor usage and start time
|
||||
|
@ -676,6 +731,7 @@ The **connection failed logs** can be used for integration in tools such as [Fai
|
|||
- [cobra](https://github.com/spf13/cobra)
|
||||
- [xid](https://github.com/rs/xid)
|
||||
- [nathanaelle/password](https://github.com/nathanaelle/password)
|
||||
- [PipeAt](https://github.com/eikenb/pipeat)
|
||||
- [ZeroConf](https://github.com/grandcat/zeroconf)
|
||||
- [SB Admin 2](https://github.com/BlackrockDigital/startbootstrap-sb-admin-2)
|
||||
|
||||
|
|
|
@ -54,13 +54,14 @@ func init() {
|
|||
Command: "",
|
||||
HTTPNotificationURL: "",
|
||||
},
|
||||
Keys: []sftpd.Key{},
|
||||
IsSCPEnabled: false,
|
||||
KexAlgorithms: []string{},
|
||||
Ciphers: []string{},
|
||||
MACs: []string{},
|
||||
LoginBannerFile: "",
|
||||
EnabledSSHCommands: sftpd.GetDefaultSSHCommands(),
|
||||
Keys: []sftpd.Key{},
|
||||
IsSCPEnabled: false,
|
||||
KexAlgorithms: []string{},
|
||||
Ciphers: []string{},
|
||||
MACs: []string{},
|
||||
LoginBannerFile: "",
|
||||
EnabledSSHCommands: sftpd.GetDefaultSSHCommands(),
|
||||
KeyboardInteractiveProgram: "",
|
||||
},
|
||||
ProviderConf: dataprovider.Config{
|
||||
Driver: "sqlite",
|
||||
|
@ -171,7 +172,7 @@ func LoadConfig(configDir, configName string) error {
|
|||
logger.Warn(logSender, "", "Configuration error: %v", err)
|
||||
logger.WarnToConsole("Configuration error: %v", err)
|
||||
}
|
||||
if globalConf.ProviderConf.ExternalAuthScope < 0 || globalConf.ProviderConf.ExternalAuthScope > 2 {
|
||||
if globalConf.ProviderConf.ExternalAuthScope < 0 || globalConf.ProviderConf.ExternalAuthScope > 7 {
|
||||
err = fmt.Errorf("invalid external_auth_scope: %v reset to 0", globalConf.ProviderConf.ExternalAuthScope)
|
||||
globalConf.ProviderConf.ExternalAuthScope = 0
|
||||
logger.Warn(logSender, "", "Configuration error: %v", err)
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
package dataprovider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
|
@ -22,8 +23,10 @@ import (
|
|||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
|
@ -168,13 +171,23 @@ type Config struct {
|
|||
// easily write his own authentication programs.
|
||||
ExternalAuthProgram string `json:"external_auth_program" mapstructure:"external_auth_program"`
|
||||
// ExternalAuthScope defines the scope for the external authentication program.
|
||||
// - 0 means all supported authetication scopes, the external program will be used for both password and
|
||||
// public key authentication
|
||||
// - 1 means passwords only, the external program will not be used for public key authentication
|
||||
// - 2 means public keys only, the external program will not be used for password authentication
|
||||
// - 0 means all supported authetication scopes, the external program will be used for password,
|
||||
// public key and keyboard interactive authentication
|
||||
// - 1 means passwords only
|
||||
// - 2 means public keys only
|
||||
// - 4 means keyboard interactive only
|
||||
// you can combine the scopes, for example 3 means password and public key, 5 password and keyboard
|
||||
// interactive and so on
|
||||
ExternalAuthScope int `json:"external_auth_scope" mapstructure:"external_auth_scope"`
|
||||
}
|
||||
|
||||
type keyboardAuthProgramResponse struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Questions []string `json:"questions"`
|
||||
Echos []bool `json:"echos"`
|
||||
AuthResult int `json:"auth_result"`
|
||||
}
|
||||
|
||||
// ValidationError raised if input data is not valid
|
||||
type ValidationError struct {
|
||||
err string
|
||||
|
@ -277,8 +290,8 @@ func Initialize(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(p Provider, username string, password string) (User, error) {
|
||||
if len(config.ExternalAuthProgram) > 0 && config.ExternalAuthScope <= 1 {
|
||||
user, err := doExternalAuth(username, password, "")
|
||||
if len(config.ExternalAuthProgram) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
|
||||
user, err := doExternalAuth(username, password, "", "")
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
@ -289,8 +302,8 @@ func CheckUserAndPass(p Provider, 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(p Provider, username string, pubKey string) (User, string, error) {
|
||||
if len(config.ExternalAuthProgram) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope == 2) {
|
||||
user, err := doExternalAuth(username, "", pubKey)
|
||||
if len(config.ExternalAuthProgram) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&2 != 0) {
|
||||
user, err := doExternalAuth(username, "", pubKey, "")
|
||||
if err != nil {
|
||||
return user, "", err
|
||||
}
|
||||
|
@ -299,6 +312,22 @@ func CheckUserAndPubKey(p Provider, username string, pubKey string) (User, strin
|
|||
return p.validateUserAndPubKey(username, pubKey)
|
||||
}
|
||||
|
||||
// CheckKeyboardInteractiveAuth checks the keyboard interactive authentication and returns
|
||||
// the authenticated user or an error
|
||||
func CheckKeyboardInteractiveAuth(p Provider, username, authProgram string, client ssh.KeyboardInteractiveChallenge) (User, error) {
|
||||
var user User
|
||||
var err error
|
||||
if len(config.ExternalAuthProgram) > 0 && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
|
||||
user, err = doExternalAuth(username, "", "", "1")
|
||||
} else {
|
||||
user, err = p.userExists(username)
|
||||
}
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return doKeyboardInteractiveAuth(user, authProgram, client)
|
||||
}
|
||||
|
||||
// UpdateLastLogin updates the last login fields for the given SFTP user
|
||||
func UpdateLastLogin(p Provider, user User) error {
|
||||
if config.ManageUsers == 0 {
|
||||
|
@ -727,7 +756,96 @@ func checkDataprovider() {
|
|||
metrics.UpdateDataProviderAvailability(err)
|
||||
}
|
||||
|
||||
func doExternalAuth(username, password, pubKey string) (User, error) {
|
||||
func terminateInteractiveAuthProgram(cmd *exec.Cmd, isFinished bool) {
|
||||
if isFinished {
|
||||
return
|
||||
}
|
||||
providerLog(logger.LevelInfo, "kill interactive auth program after an unexpected error")
|
||||
cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func doKeyboardInteractiveAuth(user User, authProgram string, client ssh.KeyboardInteractiveChallenge) (User, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, authProgram)
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", user.Username),
|
||||
fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", user.Password))
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
var once sync.Once
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
authResult := 0
|
||||
for scanner.Scan() {
|
||||
var response keyboardAuthProgramResponse
|
||||
err := json.Unmarshal(scanner.Bytes(), &response)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelInfo, "interactive auth error parsing response: %v", err)
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
break
|
||||
}
|
||||
if response.AuthResult != 0 {
|
||||
authResult = response.AuthResult
|
||||
break
|
||||
}
|
||||
if len(response.Questions) == 0 {
|
||||
providerLog(logger.LevelInfo, "interactive auth error: program response does not contain questions")
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
break
|
||||
}
|
||||
if len(response.Questions) != len(response.Echos) {
|
||||
providerLog(logger.LevelInfo, "interactive auth error, program response questions don't match echos: %v %v",
|
||||
len(response.Questions), len(response.Echos))
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
break
|
||||
}
|
||||
go func() {
|
||||
questions := response.Questions
|
||||
answers, err := client(user.Username, response.Instruction, questions, response.Echos)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelInfo, "error getting interactive auth client response: %v", err)
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
return
|
||||
}
|
||||
if len(answers) != len(questions) {
|
||||
providerLog(logger.LevelInfo, "client answers does not match questions, expected: %v actual: %v", questions, answers)
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
return
|
||||
}
|
||||
for _, answer := range answers {
|
||||
if runtime.GOOS == "windows" {
|
||||
answer += "\r"
|
||||
}
|
||||
answer += "\n"
|
||||
_, err = stdin.Write([]byte(answer))
|
||||
if err != nil {
|
||||
providerLog(logger.LevelError, "unable to write client answer to keyboard interactive program: %v", err)
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
stdin.Close()
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, true) })
|
||||
go cmd.Process.Wait()
|
||||
if authResult != 1 {
|
||||
return user, fmt.Errorf("keyboard interactive auth failed, result: %v", authResult)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func doExternalAuth(username, password, pubKey, keyboardInteractive string) (User, error) {
|
||||
var user User
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
@ -743,10 +861,11 @@ func doExternalAuth(username, password, pubKey string) (User, error) {
|
|||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username),
|
||||
fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password),
|
||||
fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%v", pkey))
|
||||
fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%v", pkey),
|
||||
fmt.Sprintf("SFTPGO_AUTHD_KEYBOARD_INTERACTIVE=%v", keyboardInteractive))
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return user, fmt.Errorf("External auth error: %v env: %+v", err, cmd.Env)
|
||||
return user, fmt.Errorf("External auth error: %v", err)
|
||||
}
|
||||
err = json.Unmarshal(out, &user)
|
||||
if err != nil {
|
||||
|
@ -755,7 +874,9 @@ func doExternalAuth(username, password, pubKey string) (User, error) {
|
|||
if len(user.Username) == 0 {
|
||||
return user, errors.New("Invalid credentials")
|
||||
}
|
||||
user.Password = password
|
||||
if len(password) > 0 {
|
||||
user.Password = password
|
||||
}
|
||||
if len(pkey) > 0 && !utils.IsStringPrefixInSlice(pkey, user.PublicKeys) {
|
||||
user.PublicKeys = append(user.PublicKeys, pkey)
|
||||
}
|
||||
|
|
|
@ -127,6 +127,27 @@ var (
|
|||
Help: "The total number of failed logins using a public key",
|
||||
})
|
||||
|
||||
// totalInteractiveLoginAttempts is the metric that reports the total number of login attempts
|
||||
// using keyboard interactive authentication
|
||||
totalInteractiveLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sftpgo_keyboard_interactive_login_attempts_total",
|
||||
Help: "The total number of login attempts using keyboard interactive authentication",
|
||||
})
|
||||
|
||||
// totalInteractiveLoginOK is the metric that reports the total number of successful logins
|
||||
// using keyboard interactive authentication
|
||||
totalInteractiveLoginOK = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sftpgo_keyboard_interactive_login_ok_total",
|
||||
Help: "The total number of successful logins using keyboard interactive authentication",
|
||||
})
|
||||
|
||||
// totalInteractiveLoginFailed is the metric that reports the total number of failed logins
|
||||
// using keyboard interactive authentication
|
||||
totalInteractiveLoginFailed = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sftpgo_keyboard_interactive_login_ko_total",
|
||||
Help: "The total number of failed logins using keyboard interactive authentication",
|
||||
})
|
||||
|
||||
totalHTTPRequests = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sftpgo_http_req_total",
|
||||
Help: "The total number of HTTP requests served",
|
||||
|
@ -188,29 +209,38 @@ func UpdateDataProviderAvailability(err error) {
|
|||
}
|
||||
|
||||
// AddLoginAttempt increments the metrics for login attempts
|
||||
func AddLoginAttempt(withKey bool) {
|
||||
func AddLoginAttempt(authMethod string) {
|
||||
totalLoginAttempts.Inc()
|
||||
if withKey {
|
||||
switch authMethod {
|
||||
case "public_key":
|
||||
totalKeyLoginAttempts.Inc()
|
||||
} else {
|
||||
case "keyboard-interactive":
|
||||
totalInteractiveLoginAttempts.Inc()
|
||||
default:
|
||||
totalPasswordLoginAttempts.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// AddLoginResult increments the metrics for login results
|
||||
func AddLoginResult(withKey bool, err error) {
|
||||
func AddLoginResult(authMethod string, err error) {
|
||||
if err == nil {
|
||||
totalLoginOK.Inc()
|
||||
if withKey {
|
||||
switch authMethod {
|
||||
case "public_key":
|
||||
totalKeyLoginOK.Inc()
|
||||
} else {
|
||||
case "keyboard-interactive":
|
||||
totalInteractiveLoginOK.Inc()
|
||||
default:
|
||||
totalPasswordLoginOK.Inc()
|
||||
}
|
||||
} else {
|
||||
totalLoginFailed.Inc()
|
||||
if withKey {
|
||||
switch authMethod {
|
||||
case "public_key":
|
||||
totalKeyLoginFailed.Inc()
|
||||
} else {
|
||||
case "keyboard-interactive":
|
||||
totalInteractiveLoginFailed.Inc()
|
||||
default:
|
||||
totalPasswordLoginFailed.Inc()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,6 +97,9 @@ type Configuration struct {
|
|||
// The following SSH commands are enabled by default: "md5sum", "sha1sum", "cd", "pwd".
|
||||
// "*" enables all supported SSH commands.
|
||||
EnabledSSHCommands []string `json:"enabled_ssh_commands" mapstructure:"enabled_ssh_commands"`
|
||||
// Absolute path to an external program to use for keyboard interactive authentication.
|
||||
// Leave empty to disable this authentication mode.
|
||||
KeyboardInteractiveProgram string `json:"keyboard_interactive_auth_program" mapstructure:"keyboard_interactive_auth_program"`
|
||||
}
|
||||
|
||||
// Key contains information about host keys
|
||||
|
@ -171,6 +174,7 @@ func (c Configuration) Initialize(configDir string) error {
|
|||
}
|
||||
|
||||
c.configureSecurityOptions(serverConfig)
|
||||
c.configureKeyboardInteractiveAuth(serverConfig)
|
||||
c.configureLoginBanner(serverConfig, configDir)
|
||||
c.configureSFTPExtensions()
|
||||
c.checkSSHCommands()
|
||||
|
@ -230,6 +234,33 @@ func (c Configuration) configureLoginBanner(serverConfig *ssh.ServerConfig, conf
|
|||
return err
|
||||
}
|
||||
|
||||
func (c Configuration) configureKeyboardInteractiveAuth(serverConfig *ssh.ServerConfig) {
|
||||
if len(c.KeyboardInteractiveProgram) == 0 {
|
||||
return
|
||||
}
|
||||
if !filepath.IsAbs(c.KeyboardInteractiveProgram) {
|
||||
logger.WarnToConsole("invalid keyboard interactive authentication program: %#v must be an absolute path",
|
||||
c.KeyboardInteractiveProgram)
|
||||
logger.Warn(logSender, "", "invalid keyboard interactive authentication program: %#v must be an absolute path",
|
||||
c.KeyboardInteractiveProgram)
|
||||
return
|
||||
}
|
||||
_, err := os.Stat(c.KeyboardInteractiveProgram)
|
||||
if err != nil {
|
||||
logger.WarnToConsole("invalid keyboard interactive authentication program:: %v", err)
|
||||
logger.Warn(logSender, "", "invalid keyboard interactive authentication program:: %v", err)
|
||||
return
|
||||
}
|
||||
serverConfig.KeyboardInteractiveCallback = func(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
|
||||
sp, err := c.validateKeyboardInteractiveCredentials(conn, client)
|
||||
if err != nil {
|
||||
return nil, &authenticationError{err: fmt.Sprintf("could not validate keyboard interactive credentials: %v", err)}
|
||||
}
|
||||
|
||||
return sp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c Configuration) configureSFTPExtensions() error {
|
||||
err := sftp.SetSFTPExtensions(sftpExtensions...)
|
||||
if err != nil {
|
||||
|
@ -258,12 +289,11 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server
|
|||
conn.SetDeadline(time.Time{})
|
||||
|
||||
var user dataprovider.User
|
||||
var loginType string
|
||||
|
||||
// Unmarshal cannot fails here and even if it fails we'll have a user with no permissions
|
||||
json.Unmarshal([]byte(sconn.Permissions.Extensions["user"]), &user)
|
||||
|
||||
loginType = sconn.Permissions.Extensions["login_type"]
|
||||
loginType := sconn.Permissions.Extensions["login_type"]
|
||||
connectionID := hex.EncodeToString(sconn.SessionID())
|
||||
|
||||
fs, err := user.GetFilesystem(connectionID)
|
||||
|
@ -435,13 +465,14 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe
|
|||
var keyID string
|
||||
var sshPerm *ssh.Permissions
|
||||
|
||||
metrics.AddLoginAttempt(true)
|
||||
method := "public_key"
|
||||
metrics.AddLoginAttempt(method)
|
||||
if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey); err == nil {
|
||||
sshPerm, err = loginUser(user, "public_key:"+keyID, conn.RemoteAddr().String())
|
||||
sshPerm, err = loginUser(user, fmt.Sprintf("%v:%v", method, keyID), conn.RemoteAddr().String())
|
||||
} else {
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "public_key", err.Error())
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error())
|
||||
}
|
||||
metrics.AddLoginResult(true, err)
|
||||
metrics.AddLoginResult(method, err)
|
||||
return sshPerm, err
|
||||
}
|
||||
|
||||
|
@ -450,13 +481,30 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [
|
|||
var user dataprovider.User
|
||||
var sshPerm *ssh.Permissions
|
||||
|
||||
metrics.AddLoginAttempt(false)
|
||||
method := "password"
|
||||
metrics.AddLoginAttempt(method)
|
||||
if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil {
|
||||
sshPerm, err = loginUser(user, "password", conn.RemoteAddr().String())
|
||||
sshPerm, err = loginUser(user, method, conn.RemoteAddr().String())
|
||||
} else {
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "password", err.Error())
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error())
|
||||
}
|
||||
metrics.AddLoginResult(false, err)
|
||||
metrics.AddLoginResult(method, err)
|
||||
return sshPerm, err
|
||||
}
|
||||
|
||||
func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
|
||||
var err error
|
||||
var user dataprovider.User
|
||||
var sshPerm *ssh.Permissions
|
||||
|
||||
method := "keyboard-interactive"
|
||||
metrics.AddLoginAttempt(method)
|
||||
if user, err = dataprovider.CheckKeyboardInteractiveAuth(dataProvider, conn.User(), c.KeyboardInteractiveProgram, client); err == nil {
|
||||
sshPerm, err = loginUser(user, method, conn.RemoteAddr().String())
|
||||
} else {
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error())
|
||||
}
|
||||
metrics.AddLoginResult(method, err)
|
||||
return sshPerm, err
|
||||
}
|
||||
|
||||
|
|
|
@ -98,6 +98,7 @@ var (
|
|||
privateKeyPath string
|
||||
gitWrapPath string
|
||||
extAuthPath string
|
||||
keyIntAuthPath string
|
||||
logFilePath string
|
||||
)
|
||||
|
||||
|
@ -141,6 +142,9 @@ func TestMain(m *testing.M) {
|
|||
sftpdConf.Actions.HTTPNotificationURL = "http://127.0.0.1:8080/"
|
||||
scriptArgs = "$@"
|
||||
}
|
||||
keyIntAuthPath = filepath.Join(homeBasePath, "keyintauth.sh")
|
||||
ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755)
|
||||
sftpdConf.KeyboardInteractiveProgram = keyIntAuthPath
|
||||
|
||||
scpPath, err = exec.LookPath("scp")
|
||||
if err != nil {
|
||||
|
@ -206,6 +210,7 @@ func TestMain(m *testing.M) {
|
|||
os.Remove(privateKeyPath)
|
||||
os.Remove(gitWrapPath)
|
||||
os.Remove(extAuthPath)
|
||||
os.Remove(keyIntAuthPath)
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
|
@ -221,6 +226,16 @@ func TestInitialization(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Errorf("Inizialize must fail, a SFTP server should be already running")
|
||||
}
|
||||
sftpdConf.KeyboardInteractiveProgram = "invalid_file"
|
||||
err = sftpdConf.Initialize(configDir)
|
||||
if err == nil {
|
||||
t.Errorf("Inizialize must fail, a SFTP server should be already running")
|
||||
}
|
||||
sftpdConf.KeyboardInteractiveProgram = filepath.Join(homeBasePath, "invalid_file")
|
||||
err = sftpdConf.Initialize(configDir)
|
||||
if err == nil {
|
||||
t.Errorf("Inizialize must fail, a SFTP server should be already running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicSFTPHandling(t *testing.T) {
|
||||
|
@ -1179,6 +1194,51 @@ func TestLoginAfterUserUpdateEmptyPubKey(t *testing.T) {
|
|||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestLoginKeyboardInteractiveAuth(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("this test is not available on Windows")
|
||||
}
|
||||
user, _, err := httpd.AddUser(getTestUser(false), http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755)
|
||||
client, err := getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
|
||||
if err != nil {
|
||||
t.Errorf("unable to create sftp client: %v", err)
|
||||
} else {
|
||||
defer client.Close()
|
||||
_, err := client.Getwd()
|
||||
if err != nil {
|
||||
t.Errorf("unable to get working dir: %v", err)
|
||||
}
|
||||
_, err = client.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Errorf("unable to read remote dir: %v", err)
|
||||
}
|
||||
}
|
||||
ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, -1), 0755)
|
||||
client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
|
||||
if err == nil {
|
||||
t.Error("keyboard interactive auth must fail the script returned -1")
|
||||
}
|
||||
ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, true, 1), 0755)
|
||||
client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
|
||||
if err == nil {
|
||||
t.Error("keyboard interactive auth must fail the script returned bad json")
|
||||
}
|
||||
ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 5, true, 1), 0755)
|
||||
client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
|
||||
if err == nil {
|
||||
t.Error("keyboard interactive auth must fail the script returned bad json")
|
||||
}
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove user: %v", err)
|
||||
}
|
||||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestLoginExternalAuthPwdAndPubKey(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("this test is not available on Windows")
|
||||
|
@ -1406,6 +1466,75 @@ func TestLoginExternalAuthPubKey(t *testing.T) {
|
|||
os.Remove(extAuthPath)
|
||||
}
|
||||
|
||||
func TestLoginExternalAuthInteractive(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("this test is not available on Windows")
|
||||
}
|
||||
usePubKey := false
|
||||
u := getTestUser(usePubKey)
|
||||
dataProvider := dataprovider.GetProvider()
|
||||
dataprovider.Close(dataProvider)
|
||||
config.LoadConfig(configDir, "")
|
||||
providerConf := config.GetProviderConf()
|
||||
ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, 0, false), 0755)
|
||||
providerConf.ExternalAuthProgram = extAuthPath
|
||||
providerConf.ExternalAuthScope = 4
|
||||
err := dataprovider.Initialize(providerConf, configDir)
|
||||
if err != nil {
|
||||
t.Errorf("error initializing data provider")
|
||||
}
|
||||
httpd.SetDataProvider(dataprovider.GetProvider())
|
||||
sftpd.SetDataProvider(dataprovider.GetProvider())
|
||||
|
||||
ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755)
|
||||
client, err := getKeyboardInteractiveSftpClient(u, []string{"1", "2"})
|
||||
if err != nil {
|
||||
t.Errorf("unable to create sftp client: %v", err)
|
||||
} else {
|
||||
defer client.Close()
|
||||
_, err := client.Getwd()
|
||||
if err != nil {
|
||||
t.Errorf("unable to get working dir: %v", err)
|
||||
}
|
||||
}
|
||||
u.Username = defaultUsername + "1"
|
||||
client, err = getKeyboardInteractiveSftpClient(u, []string{"1", "2"})
|
||||
if err == nil {
|
||||
t.Error("external auth login with invalid user must fail")
|
||||
}
|
||||
usePubKey = true
|
||||
u = getTestUser(usePubKey)
|
||||
client, err = getSftpClient(u, usePubKey)
|
||||
if err == nil {
|
||||
t.Error("external auth login with valid user but invalid auth scope must fail")
|
||||
}
|
||||
users, out, err := httpd.GetUsers(0, 0, defaultUsername, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to get users: %v, out: %v", err, string(out))
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Errorf("number of users mismatch, expected: 1, actual: %v", len(users))
|
||||
}
|
||||
user := users[0]
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove: %v", err)
|
||||
}
|
||||
os.RemoveAll(user.GetHomeDir())
|
||||
|
||||
dataProvider = dataprovider.GetProvider()
|
||||
dataprovider.Close(dataProvider)
|
||||
config.LoadConfig(configDir, "")
|
||||
providerConf = config.GetProviderConf()
|
||||
err = dataprovider.Initialize(providerConf, configDir)
|
||||
if err != nil {
|
||||
t.Errorf("error initializing data provider")
|
||||
}
|
||||
httpd.SetDataProvider(dataprovider.GetProvider())
|
||||
sftpd.SetDataProvider(dataprovider.GetProvider())
|
||||
os.Remove(extAuthPath)
|
||||
}
|
||||
|
||||
func TestLoginExternalAuthErrors(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("this test is not available on Windows")
|
||||
|
@ -4149,6 +4278,27 @@ func getSftpClient(user dataprovider.User, usePubKey bool) (*sftp.Client, error)
|
|||
return sftpClient, err
|
||||
}
|
||||
|
||||
func getKeyboardInteractiveSftpClient(user dataprovider.User, answers []string) (*sftp.Client, error) {
|
||||
var sftpClient *sftp.Client
|
||||
config := &ssh.ClientConfig{
|
||||
User: user.Username,
|
||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
return nil
|
||||
},
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return answers, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
conn, err := ssh.Dial("tcp", sftpServerAddr, config)
|
||||
if err != nil {
|
||||
return sftpClient, err
|
||||
}
|
||||
sftpClient, err = sftp.NewClient(conn)
|
||||
return sftpClient, err
|
||||
}
|
||||
|
||||
func createTestFile(path string, size int64) error {
|
||||
baseDir := filepath.Dir(path)
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
|
@ -4470,6 +4620,29 @@ func addFileToGitRepo(repoPath string, fileSize int64) ([]byte, error) {
|
|||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
func getKeyboardInteractiveScriptContent(questions []string, sleepTime int, nonJsonResponse bool, result int) []byte {
|
||||
content := []byte("#!/bin/sh\n\n")
|
||||
q, _ := json.Marshal(questions)
|
||||
echos := []bool{}
|
||||
for index, _ := range questions {
|
||||
echos = append(echos, index%2 == 0)
|
||||
}
|
||||
e, _ := json.Marshal(echos)
|
||||
if nonJsonResponse {
|
||||
content = append(content, []byte(fmt.Sprintf("echo 'questions: %v echos: %v\n", string(q), string(e)))...)
|
||||
} else {
|
||||
content = append(content, []byte(fmt.Sprintf("echo '{\"questions\":%v,\"echos\":%v}'\n", string(q), string(e)))...)
|
||||
}
|
||||
for index, _ := range questions {
|
||||
content = append(content, []byte(fmt.Sprintf("read ANSWER%v\n", index))...)
|
||||
}
|
||||
if sleepTime > 0 {
|
||||
content = append(content, []byte(fmt.Sprintf("sleep %v\n", sleepTime))...)
|
||||
}
|
||||
content = append(content, []byte(fmt.Sprintf("echo '{\"auth_result\":%v}'\n", result))...)
|
||||
return content
|
||||
}
|
||||
|
||||
func getExtAuthScriptContent(user dataprovider.User, sleepTime int, nonJsonResponse bool) []byte {
|
||||
extAuthContent := []byte("#!/bin/sh\n\n")
|
||||
u, _ := json.Marshal(user)
|
||||
|
|
|
@ -19,7 +19,8 @@
|
|||
"macs": [],
|
||||
"login_banner_file": "",
|
||||
"setstat_mode": 0,
|
||||
"enabled_ssh_commands": ["md5sum", "sha1sum", "cd", "pwd"]
|
||||
"enabled_ssh_commands": ["md5sum", "sha1sum", "cd", "pwd"],
|
||||
"keyboard_interactive_auth_program": ""
|
||||
},
|
||||
"data_provider": {
|
||||
"driver": "sqlite",
|
||||
|
|
Loading…
Reference in a new issue