keyboard interactive hooks: allow to validate passcode

This commit is contained in:
Nicola Murino 2021-12-04 15:14:44 +01:00
parent 3f5451eab6
commit dc1cc88a46
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
7 changed files with 195 additions and 15 deletions

View file

@ -2348,12 +2348,33 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
return answers, err
}
if len(answers) == 1 && response.CheckPwd > 0 {
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"
}
return answers, err

View file

@ -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.

View file

@ -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
@ -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
@ -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){

View file

@ -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

View file

@ -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"
@ -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"
@ -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()
@ -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)
}
@ -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")
@ -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)

View file

@ -121,7 +121,7 @@
cm.setOption("mode", mode.mode);
}
cm.setValue("{{.Data}}");
setInterval(keepAlive, 180000);
setInterval(keepAlive, 300000);
});
function keepAlive() {

View file

@ -198,7 +198,7 @@
childReference = window.open(url, '_blank');
if (!checkerStarted){
keepAlive();
setInterval(checkExternalWindow, 180000);
setInterval(checkExternalWindow, 300000);
checkerStarted = true;
}
} else {
@ -212,7 +212,7 @@
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;
}
@ -230,7 +230,7 @@
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){
@ -561,7 +561,7 @@
$("#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;