mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 23:20:24 +00:00
keyboard interactive hooks: allow to validate passcode
This commit is contained in:
parent
3f5451eab6
commit
dc1cc88a46
7 changed files with 195 additions and 15 deletions
|
@ -2348,11 +2348,32 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
|
||||||
return answers, err
|
return answers, err
|
||||||
}
|
}
|
||||||
if len(answers) == 1 && response.CheckPwd > 0 {
|
if len(answers) == 1 && response.CheckPwd > 0 {
|
||||||
_, err = checkUserAndPass(user, answers[0], ip, protocol)
|
if response.CheckPwd == 2 {
|
||||||
providerLog(logger.LevelInfo, "interactive auth hook requested password validation for user %#v, validation error: %v",
|
if !user.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(protocolSSH, user.Filters.TOTPConfig.Protocols) {
|
||||||
user.Username, err)
|
providerLog(logger.LevelInfo, "keyboard interactive auth error: unable to check TOTP passcode, TOTP is not enabled for user %#v",
|
||||||
if err != nil {
|
user.Username)
|
||||||
return answers, err
|
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"
|
answers[0] = "OK"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
- `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
|
- `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.
|
- `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.
|
- `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.
|
SFTPGo writes the user answers to the program standard input, one per line, in the same order as the questions.
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
// in real world usage set the origin when you call postMessage, we use `*` for testing purpose here
|
// in real world usage set the origin when you call postMessage, we use `*` for testing purpose here
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
if (window.opener == null || window.opener.closed) {
|
if (window.opener == null || window.opener.closed) {
|
||||||
console.log("windows opener gone!");
|
console.log("window opener gone!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// notify SFTPGo that the page is ready to receive the file
|
// notify SFTPGo that the page is ready to receive the file
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (window.opener == null || window.opener.closed) {
|
if (window.opener == null || window.opener.closed) {
|
||||||
console.log("windows opener gone!");
|
console.log("window opener gone!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// you should check the origin before continuing
|
// you should check the origin before continuing
|
||||||
|
@ -76,6 +76,10 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function saveBlob(binary){
|
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
|
// if we have modified the file we can send it back to SFTPGo as a blob for saving
|
||||||
console.log("save blob, binary? "+binary);
|
console.log("save blob, binary? "+binary);
|
||||||
if (binary){
|
if (binary){
|
||||||
|
|
|
@ -4871,7 +4871,10 @@ components:
|
||||||
- 1
|
- 1
|
||||||
- 2
|
- 2
|
||||||
description: |
|
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:
|
force_passive_ip:
|
||||||
type: string
|
type: string
|
||||||
description: External IP address to expose for passive connections
|
description: External IP address to expose for passive connections
|
||||||
|
|
|
@ -30,6 +30,8 @@ import (
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
@ -42,6 +44,7 @@ import (
|
||||||
"github.com/drakkan/sftpgo/v2/httpdtest"
|
"github.com/drakkan/sftpgo/v2/httpdtest"
|
||||||
"github.com/drakkan/sftpgo/v2/kms"
|
"github.com/drakkan/sftpgo/v2/kms"
|
||||||
"github.com/drakkan/sftpgo/v2/logger"
|
"github.com/drakkan/sftpgo/v2/logger"
|
||||||
|
"github.com/drakkan/sftpgo/v2/mfa"
|
||||||
"github.com/drakkan/sftpgo/v2/sdk"
|
"github.com/drakkan/sftpgo/v2/sdk"
|
||||||
"github.com/drakkan/sftpgo/v2/sftpd"
|
"github.com/drakkan/sftpgo/v2/sftpd"
|
||||||
"github.com/drakkan/sftpgo/v2/util"
|
"github.com/drakkan/sftpgo/v2/util"
|
||||||
|
@ -193,6 +196,12 @@ func TestMain(m *testing.M) {
|
||||||
logger.ErrorToConsole("error initializing kms: %v", err)
|
logger.ErrorToConsole("error initializing kms: %v", err)
|
||||||
os.Exit(1)
|
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()
|
sftpdConf := config.GetSFTPDConfig()
|
||||||
httpdConf := config.GetHTTPDConfig()
|
httpdConf := config.GetHTTPDConfig()
|
||||||
|
@ -312,7 +321,7 @@ func TestMain(m *testing.M) {
|
||||||
os.Remove(postConnectPath)
|
os.Remove(postConnectPath)
|
||||||
os.Remove(preDownloadPath)
|
os.Remove(preDownloadPath)
|
||||||
os.Remove(preUploadPath)
|
os.Remove(preUploadPath)
|
||||||
os.Remove(keyIntAuthPath)
|
//os.Remove(keyIntAuthPath)
|
||||||
os.Remove(checkPwdPath)
|
os.Remove(checkPwdPath)
|
||||||
os.Exit(exitCode)
|
os.Exit(exitCode)
|
||||||
}
|
}
|
||||||
|
@ -2246,6 +2255,127 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
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) {
|
func TestPreLoginScript(t *testing.T) {
|
||||||
if runtime.GOOS == osWindows {
|
if runtime.GOOS == osWindows {
|
||||||
t.Skip("this test is not available on Windows")
|
t.Skip("this test is not available on Windows")
|
||||||
|
@ -9909,6 +10039,28 @@ func addFileToGitRepo(repoPath string, fileSize int64) ([]byte, error) {
|
||||||
return cmd.CombinedOutput()
|
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 {
|
func getKeyboardInteractiveScriptContent(questions []string, sleepTime int, nonJSONResponse bool, result int) []byte {
|
||||||
content := []byte("#!/bin/sh\n\n")
|
content := []byte("#!/bin/sh\n\n")
|
||||||
q, _ := json.Marshal(questions)
|
q, _ := json.Marshal(questions)
|
||||||
|
|
|
@ -121,7 +121,7 @@
|
||||||
cm.setOption("mode", mode.mode);
|
cm.setOption("mode", mode.mode);
|
||||||
}
|
}
|
||||||
cm.setValue("{{.Data}}");
|
cm.setValue("{{.Data}}");
|
||||||
setInterval(keepAlive, 180000);
|
setInterval(keepAlive, 300000);
|
||||||
});
|
});
|
||||||
|
|
||||||
function keepAlive() {
|
function keepAlive() {
|
||||||
|
|
|
@ -198,7 +198,7 @@
|
||||||
childReference = window.open(url, '_blank');
|
childReference = window.open(url, '_blank');
|
||||||
if (!checkerStarted){
|
if (!checkerStarted){
|
||||||
keepAlive();
|
keepAlive();
|
||||||
setInterval(checkExternalWindow, 180000);
|
setInterval(checkExternalWindow, 300000);
|
||||||
checkerStarted = true;
|
checkerStarted = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -212,7 +212,7 @@
|
||||||
|
|
||||||
function notifySave(status, message){
|
function notifySave(status, message){
|
||||||
if (childReference == null || childReference.closed) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -230,7 +230,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (childReference == null || childReference.closed) {
|
if (childReference == null || childReference.closed) {
|
||||||
console.log("external windows null or closed, refusing message");
|
console.log("external window null or closed, refusing message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (event.data.type){
|
switch (event.data.type){
|
||||||
|
@ -561,7 +561,7 @@
|
||||||
$("#upload_files_form").submit(function (event){
|
$("#upload_files_form").submit(function (event){
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
keepAlive();
|
keepAlive();
|
||||||
var keepAliveTimer = setInterval(keepAlive, 180000);
|
var keepAliveTimer = setInterval(keepAlive, 300000);
|
||||||
var path = '{{.FilesURL}}?path={{.CurrentDir}}';
|
var path = '{{.FilesURL}}?path={{.CurrentDir}}';
|
||||||
|
|
||||||
var files = $("#files_name")[0].files;
|
var files = $("#files_name")[0].files;
|
||||||
|
|
Loading…
Reference in a new issue