Skip to content

Commit

Permalink
keyboard interactive hooks: allow to validate passcode
Browse files Browse the repository at this point in the history
  • Loading branch information
drakkan committed Dec 4, 2021
1 parent 3f5451e commit dc1cc88
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 15 deletions.
31 changes: 26 additions & 5 deletions dataprovider/dataprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2348,11 +2348,32 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
return answers, err
}
if len(answers) == 1 && response.CheckPwd > 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 {
return answers, err
if response.CheckPwd == 2 {
if !user.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(protocolSSH, user.Filters.TOTPConfig.Protocols) {
providerLog(logger.LevelInfo, "keyboard interactive auth error: unable to check TOTP passcode, TOTP is not enabled for user %#v",
user.Username)
return answers, errors.New("TOTP not enabled for SSH protocol")
}
err := user.Filters.TOTPConfig.Secret.TryDecrypt()
if err != nil {
providerLog(logger.LevelWarn, "unable to decrypt TOTP secret for user %#v, protocol %v, err: %v",
user.Username, protocol, err)
return answers, fmt.Errorf("unable to decrypt TOTP secret: %w", err)
}
match, err := mfa.ValidateTOTPPasscode(user.Filters.TOTPConfig.ConfigName, answers[0],
user.Filters.TOTPConfig.Secret.GetPayload())
if !match || err != nil {
providerLog(logger.LevelInfo, "keyboard interactive auth error: unable to validate passcode for user %#v, match? %v, err: %v",
user.Username, match, err)
return answers, errors.New("unable to validate TOTP passcode")
}
} else {
_, 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 {
return answers, err
}
}
answers[0] = "OK"
}
Expand Down
2 changes: 1 addition & 1 deletion docs/keyboard-interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The program must write the questions on its standard output, in a single line, u
- `instruction`, string. A short description to show to the user that is trying to authenticate. Can be empty or omitted
- `questions`, list of questions to be asked to the user
- `echos` list of boolean flags corresponding to the questions (so the lengths of both lists must be the same) and indicating whether user's reply for a particular question should be echoed on the screen while they are typing: true if it should be echoed, or false if it should be hidden.
- `check_password` optional integer. Ask exactly one question and set this field to 1 if the expected answer is the user password and you want that SFTPGo checks it for you. If the password is correct, the returned response to the program is `OK`. If the password is wrong, the program will be terminated and an authentication error will be returned to the user that is trying to authenticate.
- `check_password` optional integer. Ask exactly one question and set this field to `1` if the expected answer is the user password and you want that SFTPGo checks it for you or to `2` if the user has the SFTPGo built-in TOTP enabled and the expected answer is the user one time passcode. If the password/passcode is correct, the returned response to the program is `OK`. If the password is wrong, the program will be terminated and an authentication error will be returned to the user that is trying to authenticate.
- `auth_result`, integer. Set this field to 1 to indicate successful authentication. 0 is ignored. Any other value means authentication error. If this field is found and it is different from 0 then SFTPGo will not read any other questions from the external program, and it will finalize the authentication.

SFTPGo writes the user answers to the program standard input, one per line, in the same order as the questions.
Expand Down
8 changes: 6 additions & 2 deletions examples/webclient-integrations/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
// in real world usage set the origin when you call postMessage, we use `*` for testing purpose here
$(document).ready(function () {
if (window.opener == null || window.opener.closed) {
console.log("windows opener gone!");
console.log("window opener gone!");
return;
}
// notify SFTPGo that the page is ready to receive the file
Expand All @@ -33,7 +33,7 @@

window.addEventListener('message', (event) => {
if (window.opener == null || window.opener.closed) {
console.log("windows opener gone!");
console.log("window opener gone!");
return;
}
// you should check the origin before continuing
Expand Down Expand Up @@ -76,6 +76,10 @@
});

function saveBlob(binary){
if (window.opener == null || window.opener.closed) {
console.log("window opener gone!");
return;
}
// if we have modified the file we can send it back to SFTPGo as a blob for saving
console.log("save blob, binary? "+binary);
if (binary){
Expand Down
5 changes: 4 additions & 1 deletion openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4871,7 +4871,10 @@ components:
- 1
- 2
description: |
* `0` - clear or explicit TLS * `1` - explicit TLS required * `2` - implicit TLS
TLS mode:
* `0` - clear or explicit TLS
* `1` - explicit TLS required
* `2` - implicit TLS
force_passive_ip:
type: string
description: External IP address to expose for passive connections
Expand Down
154 changes: 153 additions & 1 deletion sftpd/sftpd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"

"github.com/pkg/sftp"
"github.com/rs/zerolog"
Expand All @@ -42,6 +44,7 @@ import (
"github.com/drakkan/sftpgo/v2/httpdtest"
"github.com/drakkan/sftpgo/v2/kms"
"github.com/drakkan/sftpgo/v2/logger"
"github.com/drakkan/sftpgo/v2/mfa"
"github.com/drakkan/sftpgo/v2/sdk"
"github.com/drakkan/sftpgo/v2/sftpd"
"github.com/drakkan/sftpgo/v2/util"
Expand Down Expand Up @@ -193,6 +196,12 @@ func TestMain(m *testing.M) {
logger.ErrorToConsole("error initializing kms: %v", err)
os.Exit(1)
}
mfaConfig := config.GetMFAConfig()
err = mfaConfig.Initialize()
if err != nil {
logger.ErrorToConsole("error initializing MFA: %v", err)
os.Exit(1)
}

sftpdConf := config.GetSFTPDConfig()
httpdConf := config.GetHTTPDConfig()
Expand Down Expand Up @@ -312,7 +321,7 @@ func TestMain(m *testing.M) {
os.Remove(postConnectPath)
os.Remove(preDownloadPath)
os.Remove(preUploadPath)
os.Remove(keyIntAuthPath)
//os.Remove(keyIntAuthPath)
os.Remove(checkPwdPath)
os.Exit(exitCode)
}
Expand Down Expand Up @@ -2246,6 +2255,127 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) {
assert.NoError(t, err)
}

func TestInteractiveLoginWithPasscode(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")
}
user, _, err := httpdtest.AddUser(getTestUser(false), http.StatusCreated)
assert.NoError(t, err)
// test password check
err = os.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptForBuiltinChecks(false, 1), os.ModePerm)
assert.NoError(t, err)
conn, client, err := getKeyboardInteractiveSftpClient(user, []string{defaultPassword})
if assert.NoError(t, err) {
defer conn.Close()
defer client.Close()
assert.NoError(t, checkBasicSFTP(client))
}
// wrong password
_, _, err = getKeyboardInteractiveSftpClient(user, []string{"wrong_password"})
assert.Error(t, err)
// correct password but the script returns an error
err = os.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptForBuiltinChecks(false, 0), os.ModePerm)
assert.NoError(t, err)
_, _, err = getKeyboardInteractiveSftpClient(user, []string{"wrong_password"})
assert.Error(t, err)
// add multi-factor authentication
configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
assert.NoError(t, err)
user.Password = defaultPassword
user.Filters.TOTPConfig = sdk.TOTPConfig{
Enabled: true,
ConfigName: configName,
Secret: kms.NewPlainSecret(secret),
Protocols: []string{common.ProtocolSSH},
}
err = dataprovider.UpdateUser(&user, "", "")
assert.NoError(t, err)

passcode, err := totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
assert.NoError(t, err)
err = os.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptForBuiltinChecks(true, 1), os.ModePerm)
assert.NoError(t, err)

passwordAsked := false
passcodeAsked := false
authMethods := []ssh.AuthMethod{
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
var answers []string
if strings.HasPrefix(questions[0], "Password") {
answers = append(answers, defaultPassword)
passwordAsked = true
} else {
answers = append(answers, passcode)
passcodeAsked = true
}
return answers, nil
}),
}
conn, client, err = getCustomAuthSftpClient(user, authMethods, "")
if assert.NoError(t, err) {
defer conn.Close()
defer client.Close()
assert.NoError(t, checkBasicSFTP(client))
}
assert.True(t, passwordAsked)
assert.True(t, passcodeAsked)
// the same passcode cannot be reused
_, _, err = getCustomAuthSftpClient(user, authMethods, "")
assert.Error(t, err)
// correct passcode but the script returns an error
configName, _, secret, _, err = mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
assert.NoError(t, err)
user.Password = defaultPassword
user.Filters.TOTPConfig = sdk.TOTPConfig{
Enabled: true,
ConfigName: configName,
Secret: kms.NewPlainSecret(secret),
Protocols: []string{common.ProtocolSSH},
}
err = dataprovider.UpdateUser(&user, "", "")
assert.NoError(t, err)
passcode, err = totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
assert.NoError(t, err)
err = os.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptForBuiltinChecks(true, 0), os.ModePerm)
assert.NoError(t, err)
passwordAsked = false
passcodeAsked = false
_, _, err = getCustomAuthSftpClient(user, authMethods, "")
assert.Error(t, err)
authMethods = []ssh.AuthMethod{
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
var answers []string
if strings.HasPrefix(questions[0], "Password") {
answers = append(answers, defaultPassword)
passwordAsked = true
} else {
answers = append(answers, passcode)
passcodeAsked = true
}
return answers, nil
}),
}
_, _, err = getCustomAuthSftpClient(user, authMethods, "")
assert.Error(t, err)
assert.True(t, passwordAsked)
assert.True(t, passcodeAsked)

_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}

func TestPreLoginScript(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")
Expand Down Expand Up @@ -9909,6 +10039,28 @@ func addFileToGitRepo(repoPath string, fileSize int64) ([]byte, error) {
return cmd.CombinedOutput()
}

func getKeyboardInteractiveScriptForBuiltinChecks(addPasscode bool, result int) []byte {
content := []byte("#!/bin/sh\n\n")
echos := []bool{false}
q, _ := json.Marshal([]string{"Password: "})
e, _ := json.Marshal(echos)
content = append(content, []byte(fmt.Sprintf("echo '{\"questions\":%v,\"echos\":%v,\"check_password\":1}'\n", string(q), string(e)))...)
content = append(content, []byte("read ANSWER\n\n")...)
content = append(content, []byte("if test \"$ANSWER\" != \"OK\"; then\n")...)
content = append(content, []byte("exit 1\n")...)
content = append(content, []byte("fi\n\n")...)
if addPasscode {
q, _ := json.Marshal([]string{"Passcode: "})
content = append(content, []byte(fmt.Sprintf("echo '{\"questions\":%v,\"echos\":%v,\"check_password\":2}'\n", string(q), string(e)))...)
content = append(content, []byte("read ANSWER\n\n")...)
content = append(content, []byte("if test \"$ANSWER\" != \"OK\"; then\n")...)
content = append(content, []byte("exit 1\n")...)
content = append(content, []byte("fi\n\n")...)
}
content = append(content, []byte(fmt.Sprintf("echo '{\"auth_result\":%v}'\n", result))...)
return content
}

func getKeyboardInteractiveScriptContent(questions []string, sleepTime int, nonJSONResponse bool, result int) []byte {
content := []byte("#!/bin/sh\n\n")
q, _ := json.Marshal(questions)
Expand Down
2 changes: 1 addition & 1 deletion templates/webclient/editfile.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ <h5 class="modal-title" id="infoModalLabel">
cm.setOption("mode", mode.mode);
}
cm.setValue("{{.Data}}");
setInterval(keepAlive, 180000);
setInterval(keepAlive, 300000);
});

function keepAlive() {
Expand Down
8 changes: 4 additions & 4 deletions templates/webclient/files.html
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ <h5 class="modal-title" id="deleteModalLabel">
childReference = window.open(url, '_blank');
if (!checkerStarted){
keepAlive();
setInterval(checkExternalWindow, 180000);
setInterval(checkExternalWindow, 300000);
checkerStarted = true;
}
} else {
Expand All @@ -212,7 +212,7 @@ <h5 class="modal-title" id="deleteModalLabel">

function notifySave(status, message){
if (childReference == null || childReference.closed) {
console.log("external windows null or closed, cannot notify save");
console.log("external window null or closed, cannot notify save");
return;
}

Expand All @@ -230,7 +230,7 @@ <h5 class="modal-title" id="deleteModalLabel">
return;
}
if (childReference == null || childReference.closed) {
console.log("external windows null or closed, refusing message");
console.log("external window null or closed, refusing message");
return;
}
switch (event.data.type){
Expand Down Expand Up @@ -561,7 +561,7 @@ <h5 class="modal-title" id="deleteModalLabel">
$("#upload_files_form").submit(function (event){
event.preventDefault();
keepAlive();
var keepAliveTimer = setInterval(keepAlive, 180000);
var keepAliveTimer = setInterval(keepAlive, 300000);
var path = '{{.FilesURL}}?path={{.CurrentDir}}';

var files = $("#files_name")[0].files;
Expand Down

0 comments on commit dc1cc88

Please sign in to comment.