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:
parent
04c9a5c008
commit
8b0a1817b3
27 changed files with 526 additions and 95 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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`
|
||||
|
|
45
docs/check-password-hook.md
Normal file
45
docs/check-password-hook.md
Normal 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.
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
3
examples/OTP/authy/checkpwd/README.md
Normal file
3
examples/OTP/authy/checkpwd/README.md
Normal 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.
|
3
examples/OTP/authy/checkpwd/go.mod
Normal file
3
examples/OTP/authy/checkpwd/go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module github.com/drakkan/sftpgo/authy/checkpwd
|
||||
|
||||
go 1.15
|
106
examples/OTP/authy/checkpwd/main.go
Normal file
106
examples/OTP/authy/checkpwd/main.go
Normal 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, "")
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
4
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
|
||||
|
|
8
go.sum
8
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=
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in a new issue