From 8b0a1817b3b48f50e07835ccb302395fb28bd780 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Wed, 19 Aug 2020 19:36:12 +0200 Subject: [PATCH] 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 --- README.md | 1 + config/config.go | 14 +- dataprovider/bolt.go | 4 +- dataprovider/dataprovider.go | 251 +++++++++++++++++++------- dataprovider/memory.go | 4 +- dataprovider/mysql.go | 4 +- dataprovider/pgsql.go | 4 +- dataprovider/sqlcommon.go | 4 +- dataprovider/sqlite.go | 4 +- docs/account.md | 2 +- docs/check-password-hook.md | 45 +++++ docs/external-auth.md | 8 +- docs/full-configuration.md | 2 + examples/OTP/authy/README.md | 3 +- examples/OTP/authy/checkpwd/README.md | 3 + examples/OTP/authy/checkpwd/go.mod | 3 + examples/OTP/authy/checkpwd/main.go | 106 +++++++++++ examples/OTP/authy/keyint/README.md | 2 +- ftpd/ftpd_test.go | 24 +++ ftpd/server.go | 4 + go.mod | 4 +- go.sum | 8 +- httpd/schema/openapi.yaml | 2 + sftpd/sftpd_test.go | 85 +++++++++ sftpgo.json | 6 +- webdavd/server.go | 4 + webdavd/webdavd_test.go | 20 ++ 27 files changed, 526 insertions(+), 95 deletions(-) create mode 100644 docs/check-password-hook.md create mode 100644 examples/OTP/authy/checkpwd/README.md create mode 100644 examples/OTP/authy/checkpwd/go.mod create mode 100644 examples/OTP/authy/checkpwd/main.go diff --git a/README.md b/README.md index 89277f34..56996226 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/config.go b/config/config.go index ee78e6be..da0f2573 100644 --- a/config/config.go +++ b/config/config.go @@ -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, diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 044e664f..2578a8ee 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -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) { diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 47d94e41..45780925 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -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() } diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 580d7def..1a2ee093 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -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) { diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 7ad0d40f..78f0964c 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -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) { diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 41a70ad1..9677655e 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -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) { diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index b41476e9..82829f1b 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -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) { diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 55cf814a..0fb9d149 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -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) { diff --git a/docs/account.md b/docs/account.md index 9e3adaa9..cba1a462 100644 --- a/docs/account.md +++ b/docs/account.md @@ -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` diff --git a/docs/check-password-hook.md b/docs/check-password-hook.md new file mode 100644 index 00000000..e7605daa --- /dev/null +++ b/docs/check-password-hook.md @@ -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. diff --git a/docs/external-auth.md b/docs/external-auth.md index bebfc89d..6ee28555 100644 --- a/docs/external-auth.md +++ b/docs/external-auth.md @@ -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. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 1c29042a..05325dde 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -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" diff --git a/examples/OTP/authy/README.md b/examples/OTP/authy/README.md index e9e77bd6..c13d2f41 100644 --- a/examples/OTP/authy/README.md +++ b/examples/OTP/authy/README.md @@ -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. diff --git a/examples/OTP/authy/checkpwd/README.md b/examples/OTP/authy/checkpwd/README.md new file mode 100644 index 00000000..ddf5f5c2 --- /dev/null +++ b/examples/OTP/authy/checkpwd/README.md @@ -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. diff --git a/examples/OTP/authy/checkpwd/go.mod b/examples/OTP/authy/checkpwd/go.mod new file mode 100644 index 00000000..289f8eb0 --- /dev/null +++ b/examples/OTP/authy/checkpwd/go.mod @@ -0,0 +1,3 @@ +module github.com/drakkan/sftpgo/authy/checkpwd + +go 1.15 diff --git a/examples/OTP/authy/checkpwd/main.go b/examples/OTP/authy/checkpwd/main.go new file mode 100644 index 00000000..b7767b63 --- /dev/null +++ b/examples/OTP/authy/checkpwd/main.go @@ -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: "", + AuthyID: 1234567, + AuthyAPIKey: "", + }) +} + +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, "") +} diff --git a/examples/OTP/authy/keyint/README.md b/examples/OTP/authy/keyint/README.md index 604cc28b..f240bad9 100644 --- a/examples/OTP/authy/keyint/README.md +++ b/examples/OTP/authy/keyint/README.md @@ -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. diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index 65fb5719..e7b7d3e4 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -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} diff --git a/ftpd/server.go b/ftpd/server.go index 29efa79e..dcf79214 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -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 { diff --git a/go.mod b/go.mod index 15c92914..f44bdb99 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 539336a9..b274d853 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 79d796e1..4d66beba 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -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: diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index d856d2e3..86b37e92 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -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) diff --git a/sftpgo.json b/sftpgo.json index c6d3b861..0e31756b 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -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 } -} \ No newline at end of file +} diff --git a/webdavd/server.go b/webdavd/server.go index 1dfe2eeb..c79a29ce 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -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 { diff --git a/webdavd/webdavd_test.go b/webdavd/webdavd_test.go index bd1bd90a..0edc8bed 100644 --- a/webdavd/webdavd_test.go +++ b/webdavd/webdavd_test.go @@ -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}