add check password hook

its main use case is to allow to easily support things like password+OTP for
protocols without keyboard interactive support such as FTP and WebDAV
This commit is contained in:
Nicola Murino 2020-08-19 19:36:12 +02:00
parent 04c9a5c008
commit 8b0a1817b3
27 changed files with 526 additions and 95 deletions

View file

@ -143,6 +143,7 @@ Directories outside the user home directory can be exposed as virtual folders, m
## Other hooks
You can get notified as soon as a new connection is established using the [Post-connect hook](./docs/post-connect-hook.md) and after each login using the [Post-login hook](./docs/post-login-hook.md).
You can use your own hook to [check passwords](./docs/check-password-hook.md).
## Storage backends

View file

@ -120,12 +120,14 @@ func init() {
ExecuteOn: []string{},
Hook: "",
},
ExternalAuthHook: "",
ExternalAuthScope: 0,
CredentialsPath: "credentials",
PreLoginHook: "",
PostLoginHook: "",
PostLoginScope: 0,
ExternalAuthHook: "",
ExternalAuthScope: 0,
CredentialsPath: "credentials",
PreLoginHook: "",
PostLoginHook: "",
PostLoginScope: 0,
CheckPasswordHook: "",
CheckPasswordScope: 0,
},
HTTPDConfig: httpd.Conf{
BindPort: 8080,

View file

@ -121,7 +121,7 @@ func (p BoltProvider) checkAvailability() error {
return err
}
func (p BoltProvider) validateUserAndPass(username string, password string) (User, error) {
func (p BoltProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
var user User
if len(password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
@ -131,7 +131,7 @@ func (p BoltProvider) validateUserAndPass(username string, password string) (Use
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, err
}
return checkUserAndPass(user, password)
return checkUserAndPass(user, password, ip, protocol)
}
func (p BoltProvider) validateUserAndPubKey(username string, pubKey []byte) (User, string, error) {

View file

@ -216,6 +216,20 @@ type Config struct {
// - 1 means notify failed logins
// - 2 means notify successful logins
PostLoginScope int `json:"post_login_scope" mapstructure:"post_login_scope"`
// Absolute path to an external program or an HTTP URL to invoke just before password
// authentication. This hook allows you to externally check the provided password,
// its main use case is to allow to easily support things like password+OTP for protocols
// without keyboard interactive support such as FTP and WebDAV. You can ask your users
// to login using a string consisting of a fixed password and a One Time Token, you
// can verify the token inside the hook and ask to SFTPGo to verify the fixed part.
CheckPasswordHook string `json:"check_password_hook" mapstructure:"check_password_hook"`
// CheckPasswordScope defines the scope for the check password hook.
// - 0 means all protocols
// - 1 means SSH
// - 2 means FTP
// - 4 means WebDAV
// you can combine the scopes, for example 6 means FTP and WebDAV
CheckPasswordScope int `json:"check_password_scope" mapstructure:"check_password_scope"`
}
// BackupData defines the structure for the backup/restore files
@ -241,6 +255,21 @@ type keyboardAuthHookResponse struct {
CheckPwd int `json:"check_password"`
}
type checkPasswordRequest struct {
Username string `json:"username"`
IP string `json:"ip"`
Password string `json:"password"`
Protocol string `json:"protocol"`
}
type checkPasswordResponse struct {
// 0 KO, 1 OK, 2 partial success, -1 not executed
Status int `json:"status"`
// for status = 2 this is the password to check against the one stored
// inside the SFTPGo data provider
ToVerify string `json:"to_verify"`
}
type virtualFoldersCompact struct {
VirtualPath string `json:"virtual_path"`
MappedPath string `json:"mapped_path"`
@ -291,7 +320,7 @@ func GetQuotaTracking() int {
// Provider defines the interface that data providers must implement.
type Provider interface {
validateUserAndPass(username string, password string) (User, error)
validateUserAndPass(username, password, ip, protocol string) (User, error)
validateUserAndPubKey(username string, pubKey []byte) (User, string, error)
updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error
getUsedQuota(username string) (int, int64, error)
@ -343,36 +372,31 @@ func Initialize(cnf Config, basePath string) error {
}
func validateHooks() error {
var hooks []string
if len(config.PreLoginHook) > 0 && !strings.HasPrefix(config.PreLoginHook, "http") {
if !filepath.IsAbs(config.PreLoginHook) {
return fmt.Errorf("invalid pre-login hook: %#v must be an absolute path", config.PreLoginHook)
}
_, err := os.Stat(config.PreLoginHook)
if err != nil {
providerLog(logger.LevelWarn, "invalid pre-login hook: %v", err)
return err
}
hooks = append(hooks, config.PreLoginHook)
}
if len(config.ExternalAuthHook) > 0 && !strings.HasPrefix(config.ExternalAuthHook, "http") {
if !filepath.IsAbs(config.ExternalAuthHook) {
return fmt.Errorf("invalid external auth hook: %#v must be an absolute path", config.ExternalAuthHook)
}
_, err := os.Stat(config.ExternalAuthHook)
if err != nil {
providerLog(logger.LevelWarn, "invalid external auth hook: %v", err)
return err
}
hooks = append(hooks, config.ExternalAuthHook)
}
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)
hooks = append(hooks, config.PostLoginHook)
}
if len(config.CheckPasswordHook) > 0 && !strings.HasPrefix(config.CheckPasswordHook, "http") {
hooks = append(hooks, config.CheckPasswordHook)
}
for _, hook := range hooks {
if !filepath.IsAbs(hook) {
return fmt.Errorf("invalid hook: %#v must be an absolute path", hook)
}
_, err := os.Stat(config.PostLoginHook)
_, err := os.Stat(hook)
if err != nil {
providerLog(logger.LevelWarn, "invalid post-login hook: %v", err)
providerLog(logger.LevelWarn, "invalid hook: %v", err)
return err
}
}
return nil
}
@ -414,16 +438,16 @@ func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
if err != nil {
return user, err
}
return checkUserAndPass(user, password)
return checkUserAndPass(user, password, ip, protocol)
}
if len(config.PreLoginHook) > 0 {
user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol)
if err != nil {
return user, err
}
return checkUserAndPass(user, password)
return checkUserAndPass(user, password, ip, protocol)
}
return provider.validateUserAndPass(username, password)
return provider.validateUserAndPass(username, password, ip, protocol)
}
// CheckUserAndPubKey retrieves the SFTP user with the given username and public key if a match is found or an error
@ -460,7 +484,7 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
if err != nil {
return user, err
}
return doKeyboardInteractiveAuth(user, authHook, client, ip)
return doKeyboardInteractiveAuth(user, authHook, client, ip, protocol)
}
// UpdateLastLogin updates the last login fields for the given SFTP user
@ -1014,7 +1038,36 @@ func checkLoginConditions(user User) error {
return nil
}
func checkUserAndPass(user User, password string) (User, error) {
func isPasswordOK(user *User, password string) (bool, error) {
match := false
var err error
if strings.HasPrefix(user.Password, argonPwdPrefix) {
match, err = argon2id.ComparePasswordAndHash(password, user.Password)
if err != nil {
providerLog(logger.LevelWarn, "error comparing password with argon hash: %v", err)
return match, err
}
} else if strings.HasPrefix(user.Password, bcryptPwdPrefix) {
if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
providerLog(logger.LevelWarn, "error comparing password with bcrypt hash: %v", err)
return match, err
}
match = true
} else if utils.IsStringPrefixInSlice(user.Password, pbkdfPwdPrefixes) {
match, err = comparePbkdf2PasswordAndHash(password, user.Password)
if err != nil {
return match, err
}
} else if utils.IsStringPrefixInSlice(user.Password, unixPwdPrefixes) {
match, err = compareUnixPasswordAndHash(user, password)
if err != nil {
return match, err
}
}
return match, err
}
func checkUserAndPass(user User, password, ip, protocol string) (User, error) {
err := checkLoginConditions(user)
if err != nil {
return user, err
@ -1022,30 +1075,26 @@ func checkUserAndPass(user User, password string) (User, error) {
if len(user.Password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
}
match := false
if strings.HasPrefix(user.Password, argonPwdPrefix) {
match, err = argon2id.ComparePasswordAndHash(password, user.Password)
if err != nil {
providerLog(logger.LevelWarn, "error comparing password with argon hash: %v", err)
return user, err
}
} else if strings.HasPrefix(user.Password, bcryptPwdPrefix) {
if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
providerLog(logger.LevelWarn, "error comparing password with bcrypt hash: %v", err)
return user, err
}
match = true
} else if utils.IsStringPrefixInSlice(user.Password, pbkdfPwdPrefixes) {
match, err = comparePbkdf2PasswordAndHash(password, user.Password)
if err != nil {
return user, err
}
} else if utils.IsStringPrefixInSlice(user.Password, unixPwdPrefixes) {
match, err = compareUnixPasswordAndHash(user, password)
if err != nil {
return user, err
}
hookResponse, err := executeCheckPasswordHook(user.Username, password, ip, protocol)
if err != nil {
providerLog(logger.LevelDebug, "error executing check password hook: %v", err)
return user, errors.New("Unable to check credentials")
}
switch hookResponse.Status {
case -1:
// no hook configured
case 1:
providerLog(logger.LevelDebug, "password accepted by check password hook")
return user, nil
case 2:
providerLog(logger.LevelDebug, "partial success from check password hook")
password = hookResponse.ToVerify
default:
providerLog(logger.LevelDebug, "password rejected by check password hook, status: %v", hookResponse.Status)
return user, errors.New("Invalid credentials")
}
match, err := isPasswordOK(&user, password)
if !match {
err = errors.New("Invalid credentials")
}
@ -1079,7 +1128,7 @@ func checkUserAndPubKey(user User, pubKey []byte) (User, string, error) {
return user, "", errors.New("Invalid credentials")
}
func compareUnixPasswordAndHash(user User, password string) (bool, error) {
func compareUnixPasswordAndHash(user *User, password string) (bool, error) {
match := false
var err error
if strings.HasPrefix(user.Password, sha512cryptPwdPrefix) {
@ -1287,7 +1336,7 @@ func sendKeyboardAuthHTTPReq(url *url.URL, request keyboardAuthHookRequest) (key
return response, err
}
func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (int, error) {
func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
authResult := 0
var url *url.URL
url, err := url.Parse(authHook)
@ -1314,7 +1363,7 @@ func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.K
if err = validateKeyboardAuthResponse(response); err != nil {
return authResult, err
}
answers, err := getKeyboardInteractiveAnswers(client, response, user)
answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
if err != nil {
return authResult, err
}
@ -1329,7 +1378,7 @@ func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.K
}
func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, response keyboardAuthHookResponse,
user User) ([]string, error) {
user User, ip, protocol string) ([]string, error) {
questions := response.Questions
answers, err := client(user.Username, response.Instruction, questions, response.Echos)
if err != nil {
@ -1342,7 +1391,7 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
return answers, err
}
if len(answers) == 1 && response.CheckPwd > 0 {
_, err = checkUserAndPass(user, answers[0])
_, err = checkUserAndPass(user, answers[0], ip, protocol)
providerLog(logger.LevelInfo, "interactive auth hook requested password validation for user %#v, validation error: %v",
user.Username, err)
if err != nil {
@ -1354,8 +1403,8 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
}
func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, response keyboardAuthHookResponse,
user User, stdin io.WriteCloser) error {
answers, err := getKeyboardInteractiveAnswers(client, response, user)
user User, stdin io.WriteCloser, ip, protocol string) error {
answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
if err != nil {
return err
}
@ -1373,7 +1422,7 @@ func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge,
return nil
}
func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (int, error) {
func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
authResult := 0
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
@ -1413,7 +1462,7 @@ func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.Ke
break
}
go func() {
err := handleProgramInteractiveQuestions(client, response, user, stdin)
err := handleProgramInteractiveQuestions(client, response, user, stdin, ip, protocol)
if err != nil {
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
}
@ -1431,13 +1480,13 @@ func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.Ke
return authResult, err
}
func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (User, error) {
func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (User, error) {
var authResult int
var err error
if strings.HasPrefix(authHook, "http") {
authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip)
authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip, protocol)
} else {
authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip)
authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip, protocol)
}
if err != nil {
return user, err
@ -1452,6 +1501,84 @@ func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardIn
return user, nil
}
func isCheckPasswordHookDefined(protocol string) bool {
if len(config.CheckPasswordHook) == 0 {
return false
}
if config.CheckPasswordScope == 0 {
return true
}
switch protocol {
case "SSH":
return config.CheckPasswordScope&1 != 0
case "FTP":
return config.CheckPasswordScope&2 != 0
case "DAV":
return config.CheckPasswordScope&4 != 0
default:
return false
}
}
func getPasswordHookResponse(username, password, ip, protocol string) ([]byte, error) {
if strings.HasPrefix(config.CheckPasswordHook, "http") {
var result []byte
var url *url.URL
url, err := url.Parse(config.CheckPasswordHook)
if err != nil {
providerLog(logger.LevelWarn, "invalid url for check password hook %#v, error: %v", config.CheckPasswordHook, err)
return result, err
}
req := checkPasswordRequest{
Username: username,
Password: password,
IP: ip,
Protocol: protocol,
}
reqAsJSON, err := json.Marshal(req)
if err != nil {
return result, err
}
httpClient := httpclient.GetHTTPClient()
resp, err := httpClient.Post(url.String(), "application/json", bytes.NewBuffer(reqAsJSON))
if err != nil {
providerLog(logger.LevelWarn, "error getting check password hook response: %v", err)
return result, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return result, fmt.Errorf("wrong http status code from chek password hook: %v, expected 200", resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, config.CheckPasswordHook)
cmd.Env = append(os.Environ(),
fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username),
fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password),
fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip),
fmt.Sprintf("SFTPGO_AUTHD_PROTOCOL=%v", protocol),
)
return cmd.Output()
}
func executeCheckPasswordHook(username, password, ip, protocol string) (checkPasswordResponse, error) {
var response checkPasswordResponse
if !isCheckPasswordHookDefined(protocol) {
response.Status = -1
return response, nil
}
out, err := getPasswordHookResponse(username, password, ip, protocol)
if err != nil {
return response, err
}
err = json.Unmarshal(out, &response)
return response, err
}
func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte) ([]byte, error) {
if strings.HasPrefix(config.PreLoginHook, "http") {
var url *url.URL
@ -1488,7 +1615,7 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []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),
fmt.Sprintf("SFTPGO_LOGIND_PROTOCOL=%v", protocol),
)
return cmd.Output()
}

View file

@ -84,7 +84,7 @@ func (p MemoryProvider) close() error {
return nil
}
func (p MemoryProvider) validateUserAndPass(username string, password string) (User, error) {
func (p MemoryProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
var user User
if len(password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
@ -94,7 +94,7 @@ func (p MemoryProvider) validateUserAndPass(username string, password string) (U
providerLog(logger.LevelWarn, "error authenticating user %#v, error: %v", username, err)
return user, err
}
return checkUserAndPass(user, password)
return checkUserAndPass(user, password, ip, protocol)
}
func (p MemoryProvider) validateUserAndPubKey(username string, pubKey []byte) (User, string, error) {

View file

@ -84,8 +84,8 @@ func (p MySQLProvider) checkAvailability() error {
return sqlCommonCheckAvailability(p.dbHandle)
}
func (p MySQLProvider) validateUserAndPass(username string, password string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
func (p MySQLProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
}
func (p MySQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {

View file

@ -83,8 +83,8 @@ func (p PGSQLProvider) checkAvailability() error {
return sqlCommonCheckAvailability(p.dbHandle)
}
func (p PGSQLProvider) validateUserAndPass(username string, password string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
func (p PGSQLProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
}
func (p PGSQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {

View file

@ -46,7 +46,7 @@ func getUserByUsername(username string, dbHandle sqlQuerier) (User, error) {
return getUserWithVirtualFolders(user, dbHandle)
}
func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sql.DB) (User, error) {
func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHandle *sql.DB) (User, error) {
var user User
if len(password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
@ -56,7 +56,7 @@ func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sq
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, err
}
return checkUserAndPass(user, password)
return checkUserAndPass(user, password, ip, protocol)
}
func sqlCommonValidateUserAndPubKey(username string, pubKey []byte, dbHandle *sql.DB) (User, string, error) {

View file

@ -106,8 +106,8 @@ func (p SQLiteProvider) checkAvailability() error {
return sqlCommonCheckAvailability(p.dbHandle)
}
func (p SQLiteProvider) validateUserAndPass(username string, password string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
func (p SQLiteProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
}
func (p SQLiteProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {

View file

@ -31,7 +31,7 @@ For each account, the following properties can be configured:
- `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32"
- `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied
- `max_upload_file_size`, max allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited. This restriction does not apply for SSH system commands such as `git` and `rsync`
- `denied_login_methods`, List of login methods not allowed. To enable multi-step authentication you have to allow only multi-step login methods. The following login methods are supported:
- `denied_login_methods`, List of login methods not allowed. To enable multi-step authentication you have to allow only multi-step login methods. If password login method is denied or no password is set then FTP and WebDAV users cannot login. The following login methods are supported:
- `publickey`
- `password`
- `keyboard-interactive`

View file

@ -0,0 +1,45 @@
# Check password hook
This hook allows you to externally check the provided password, its main use case is to allow to easily support things like password+OTP for protocols without keyboard interactive support such as FTP and WebDAV. You can ask your users to login using a string consisting of a fixed password and a One Time Token, you can verify the token inside the hook and ask to SFTPGo to verify the fixed part.
The same thing can be achieved using [External authentication](./external-auth.md) but using this hook is simpler in some use cases.
The `check password hook` can be defined as the absolute path of your program or an HTTP URL.
The expected response is a JSON serialized struct containing the following keys:
- `status` integer. 0 means KO, 1 means OK, 2 means partial success
- `to_verify` string. For `status` = 2 SFTPGo will check this password against the one stored inside SFTPGo data provider
If the hook defines an external program it can reads the following environment variables:
- `SFTPGO_AUTHD_USERNAME`
- `SFTPGO_AUTHD_PASSWORD`
- `SFTPGO_AUTHD_IP`
- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
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 write, on its standard output, the expected JSON serialized response described above.
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`
- `password`
- `ip`
- `protocol`, possible values are `SSH`, `FTP`, `DAV`
If authentication succeed the HTTP response code must be 200 and the response body must contain the expected JSON serialized response described above.
The program hook must finish within 30 seconds, the HTTP hook timeout will use the global configuration for HTTP clients.
You can also restrict the hook scope using the `check_password_scope` configuration key:
- `0` means all supported protocols.
- `1` means SSH only
- `2` means FTP only
- `4` means WebDAV only
You can combine the scopes. For example, 6 means FTP and WebDAV.
An example check password program allowing 2FA using password + one time token can be found inside the source tree [checkpwd](../examples/OTP/authy/checkpwd) directory.

View file

@ -32,10 +32,10 @@ The program hook must finish within 30 seconds, the HTTP hook timeout will use t
This method is slower than built-in authentication, but it's very flexible as anyone can easily write his own authentication hooks.
You can also restrict the authentication scope for the hook using the `external_auth_scope` configuration key:
- 0 means all supported authetication scopes. The external hook 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
- `0` means all supported authetication scopes. The external hook 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 means password and keyboard interactive, and so on.

View file

@ -131,6 +131,8 @@ The configuration file contains the following sections:
- `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.
- `check_password_hook`, string. Absolute path to an external program or an HTTP URL to invoke to check the user provided password. See [Check password hook](./check-password-hook.md) for more details. Leave empty to disable.
- `check_password_scope`, defines the scope for the check password hook. 0 means all protocols, 1 means SSH, 2 means FTP, 4 means WebDAV. You can combine the scopes, for example 6 means FTP and WebDAV.
- **"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

@ -49,9 +49,10 @@ So inside your hook you need to check:
If these conditions are met the token is valid and you allow the user to login.
We provide two examples:
We provide the following examples:
- [Keyboard interactive authentication](./keyint/README.md) for 2FA using password + Authy one time token.
- [External authentication](./extauth/README.md) using Authy one time tokens as passwords.
- [Check password hook](./checkpwd/README.md) for 2FA using a password consisting of a fixed string and a One Time Token.
Please note that these are sample programs not intended for production use, you should write your own hook based on them and you should prefer HTTP based hooks if performance is a concern.

View file

@ -0,0 +1,3 @@
# Authy 2FA via check password hook
This example shows how to use 2FA via the check password hook using a password consisting of a fixed part and an Authy TOTP token. The hook will check the TOTP token using the Authy API and SFTPGo will check the fixed part. Please read the [sample code](./main.go), it should be self explanatory.

View file

@ -0,0 +1,3 @@
module github.com/drakkan/sftpgo/authy/checkpwd
go 1.15

View file

@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"time"
)
type userMapping struct {
SFTPGoUsername string
AuthyID int64
AuthyAPIKey string
}
type checkPasswordResponse struct {
// 0 KO, 1 OK, 2 partial success
Status int `json:"status"`
// for status == 2 this is the password that SFTPGo will check against the one stored
// inside the data provider
ToVerify string `json:"to_verify"`
}
var (
mapping []userMapping
)
func init() {
// this is for demo only, you probably want to get this mapping dynamically, for example using a database query
mapping = append(mapping, userMapping{
SFTPGoUsername: "<SFTPGo username>",
AuthyID: 1234567,
AuthyAPIKey: "<your api key>",
})
}
func printResponse(status int, toVerify string) {
r := checkPasswordResponse{
Status: status,
ToVerify: toVerify,
}
resp, _ := json.Marshal(r)
fmt.Printf("%v\n", string(resp))
if status > 0 {
os.Exit(0)
} else {
os.Exit(1)
}
}
func main() {
// get credentials from env vars
username := os.Getenv("SFTPGO_AUTHD_USERNAME")
password := os.Getenv("SFTPGO_AUTHD_PASSWORD")
for _, m := range mapping {
if m.SFTPGoUsername == username {
// Authy token len is 7, we assume that we have the password followed by the token
pwdLen := len(password)
if pwdLen <= 7 {
printResponse(0, "")
}
pwd := password[:pwdLen-7]
authyToken := password[pwdLen-7:]
// now verify the authy token and instruct SFTPGo to check the password if the token is OK
url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", authyToken, m.AuthyID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Authy-API-Key", m.AuthyAPIKey)
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := httpClient.Do(req)
if err != nil {
printResponse(0, "")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// status code 200 is expected
printResponse(0, "")
}
var authyResponse map[string]interface{}
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
printResponse(0, "")
}
err = json.Unmarshal(respBody, &authyResponse)
if err != nil {
printResponse(0, "")
}
if authyResponse["success"].(string) == "true" {
printResponse(2, pwd)
}
printResponse(0, "")
break
}
}
// no mapping found
printResponse(0, "")
}

View file

@ -1,3 +1,3 @@
# Authy keyboard interactive authentication
# Authy 2FA using keyboard interactive authentication
This example shows how to authenticate SFTP users using 2FA (password + Authy token). Please read the [sample code](./main.go), it should be self explanatory.

View file

@ -676,6 +676,30 @@ func TestResume(t *testing.T) {
assert.NoError(t, err)
}
//nolint:dupl
func TestDeniedLoginMethod(t *testing.T) {
u := getTestUser()
u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err)
_, err = getFTPClient(user, false)
assert.Error(t, err)
user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyAndPassword}
user, _, err = httpd.UpdateUser(user, http.StatusOK)
assert.NoError(t, err)
client, err := getFTPClient(user, true)
if assert.NoError(t, err) {
assert.NoError(t, checkBasicFTP(client))
err = client.Quit()
assert.NoError(t, err)
}
_, err = httpd.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
//nolint:dupl
func TestDeniedProtocols(t *testing.T) {
u := getTestUser()
u.Filters.DeniedProtocols = []string{common.ProtocolFTP}

View file

@ -161,6 +161,10 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
logger.Debug(logSender, connectionID, "cannot login user %#v, protocol FTP is not allowed", user.Username)
return nil, fmt.Errorf("Protocol FTP is not allowed for user %#v", user.Username)
}
if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
return nil, fmt.Errorf("Password login method is not allowed for user %#v", user.Username)
}
if user.MaxSessions > 0 {
activeSessions := common.Connections.GetActiveSessions(user.Username)
if activeSessions >= user.MaxSessions {

4
go.mod
View file

@ -24,7 +24,7 @@ require (
github.com/otiai10/copy v1.2.0
github.com/pelletier/go-toml v1.8.0 // indirect
github.com/pires/go-proxyproto v0.1.3
github.com/pkg/sftp v1.11.1-0.20200731124947-b508b936bef3
github.com/pkg/sftp v1.11.1-0.20200819110714-3ee8d0ba91c0
github.com/prometheus/client_golang v1.7.1
github.com/prometheus/common v0.12.0 // indirect
github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b
@ -41,7 +41,7 @@ require (
go.etcd.io/bbolt v1.3.5
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed
golang.org/x/sys v0.0.0-20200819091447-39769834ee22
golang.org/x/tools v0.0.0-20200814230902-9882f1d1823d // indirect
google.golang.org/api v0.30.0
google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70 // indirect

8
go.sum
View file

@ -365,8 +365,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.11.1-0.20200731124947-b508b936bef3 h1:Rmu3suy8Whb1i+nmhVke1aeZRB+lrjghG81bZVstpLc=
github.com/pkg/sftp v1.11.1-0.20200731124947-b508b936bef3/go.mod h1:i24A96cQ6ZvWut9G/Uv3LvC4u3VebGsBR5JFvPyChLc=
github.com/pkg/sftp v1.11.1-0.20200819110714-3ee8d0ba91c0 h1:3LRfXAQrcWKQ0LQZ9wjp9wgzYRsxFKVjP5zZIEv9NFY=
github.com/pkg/sftp v1.11.1-0.20200819110714-3ee8d0ba91c0/go.mod h1:i24A96cQ6ZvWut9G/Uv3LvC4u3VebGsBR5JFvPyChLc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@ -578,8 +578,8 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200722175500-76b94024e4b6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed h1:J22ig1FUekjjkmZUM7pTKixYm8DvrYsvrBZdunYeIuQ=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200819091447-39769834ee22 h1:YUxhQGxYV280Da2a0XZiHblyJZN6NuXS1f4dahsm0SM=
golang.org/x/sys v0.0.0-20200819091447-39769834ee22/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View file

@ -1479,6 +1479,8 @@ components:
- 'keyboard-interactive'
- 'publickey+password'
- 'publickey+keyboard-interactive'
description: >
To enable multi-step authentication you have to allow only multi-step login methods. If password login method is denied or no password is set then FTP and WebDAV users cannot login
SupportedProtocols:
type: string
enum:

View file

@ -129,6 +129,7 @@ var (
keyIntAuthPath string
preLoginPath string
postConnectPath string
checkPwdPath string
logFilePath string
)
@ -203,6 +204,7 @@ func TestMain(m *testing.M) {
extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
preLoginPath = filepath.Join(homeBasePath, "prelogin.sh")
postConnectPath = filepath.Join(homeBasePath, "postconnect.sh")
checkPwdPath = filepath.Join(homeBasePath, "checkpwd.sh")
err = ioutil.WriteFile(pubKeyPath, []byte(testPubKey+"\n"), 0600)
if err != nil {
logger.WarnToConsole("unable to save public key to file: %v", err)
@ -277,6 +279,7 @@ func TestMain(m *testing.M) {
os.Remove(preLoginPath)
os.Remove(postConnectPath)
os.Remove(keyIntAuthPath)
os.Remove(checkPwdPath)
os.Exit(exitCode)
}
@ -1487,6 +1490,77 @@ func TestPostConnectHook(t *testing.T) {
common.Config.PostConnectHook = ""
}
func TestCheckPwdHook(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")
}
usePubKey := false
u := getTestUser(usePubKey)
u.QuotaFiles = 1000
err := dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf := config.GetProviderConf()
err = ioutil.WriteFile(checkPwdPath, getCheckPwdScriptsContents(2, defaultPassword), os.ModePerm)
assert.NoError(t, err)
providerConf.CheckPasswordHook = checkPwdPath
providerConf.CheckPasswordScope = 1
err = dataprovider.Initialize(providerConf, configDir)
assert.NoError(t, err)
user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err)
client, err := getSftpClient(user, usePubKey)
if assert.NoError(t, err) {
err = checkBasicSFTP(client)
assert.NoError(t, err)
client.Close()
}
err = ioutil.WriteFile(checkPwdPath, getCheckPwdScriptsContents(0, defaultPassword), os.ModePerm)
assert.NoError(t, err)
client, err = getSftpClient(user, usePubKey)
if !assert.Error(t, err) {
client.Close()
}
err = ioutil.WriteFile(checkPwdPath, getCheckPwdScriptsContents(1, ""), os.ModePerm)
assert.NoError(t, err)
user.Password = defaultPassword + "1"
client, err = getSftpClient(user, usePubKey)
if assert.NoError(t, err) {
err = checkBasicSFTP(client)
assert.NoError(t, err)
client.Close()
}
err = dataprovider.Close()
assert.NoError(t, err)
providerConf.CheckPasswordScope = 6
err = dataprovider.Initialize(providerConf, configDir)
assert.NoError(t, err)
client, err = getSftpClient(user, usePubKey)
if !assert.Error(t, err) {
client.Close()
}
_, err = httpd.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
err = dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf = config.GetProviderConf()
err = dataprovider.Initialize(providerConf, configDir)
assert.NoError(t, err)
err = os.Remove(checkPwdPath)
assert.NoError(t, err)
}
func TestLoginExternalAuthPwdAndPubKey(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")
@ -7557,6 +7631,17 @@ func getPostConnectScriptContent(exitCode int) []byte {
return content
}
func getCheckPwdScriptsContents(status int, toVerify string) []byte {
content := []byte("#!/bin/sh\n\n")
content = append(content, []byte(fmt.Sprintf("echo '{\"status\":%v,\"to_verify\":\"%v\"}'\n", status, toVerify))...)
if status > 0 {
content = append(content, []byte("exit 0")...)
} else {
content = append(content, []byte("exit 1")...)
}
return content
}
func printLatestLogs(maxNumberOfLines int) {
var lines []string
f, err := os.Open(logFilePath)

View file

@ -84,7 +84,9 @@
"credentials_path": "credentials",
"pre_login_hook": "",
"post_login_hook": "",
"post_login_scope": 0
"post_login_scope": 0,
"check_password_hook": "",
"check_password_scope": 0
},
"httpd": {
"bind_port": 8080,
@ -101,4 +103,4 @@
"ca_certificates": [],
"skip_tls_verify": false
}
}
}

View file

@ -184,6 +184,10 @@ func (s *webDavServer) validateUser(user dataprovider.User, r *http.Request) (st
logger.Debug(logSender, connectionID, "cannot login user %#v, protocol DAV is not allowed", user.Username)
return connID, fmt.Errorf("Protocol DAV is not allowed for user %#v", user.Username)
}
if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
return connID, fmt.Errorf("Password login method is not allowed for user %#v", user.Username)
}
if user.MaxSessions > 0 {
activeSessions := common.Connections.GetActiveSessions(user.Username)
if activeSessions >= user.MaxSessions {

View file

@ -566,6 +566,26 @@ func TestUploadErrors(t *testing.T) {
assert.NoError(t, err)
}
func TestDeniedLoginMethod(t *testing.T) {
u := getTestUser()
u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err)
client := getWebDavClient(user)
assert.Error(t, checkBasicFunc(client))
user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyAndKeyboardInt}
user, _, err = httpd.UpdateUser(user, http.StatusOK)
assert.NoError(t, err)
client = getWebDavClient(user)
assert.NoError(t, checkBasicFunc(client))
_, err = httpd.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestDeniedProtocols(t *testing.T) {
u := getTestUser()
u.Filters.DeniedProtocols = []string{common.ProtocolWebDAV}