mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 23:20:24 +00:00
add support for auth plugins
This commit is contained in:
parent
ced2e16f41
commit
a20373b613
17 changed files with 1885 additions and 91 deletions
|
@ -16,7 +16,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/v2/util"
|
||||
)
|
||||
|
||||
// HostEvent is the enumerable for the support host event
|
||||
// HostEvent is the enumerable for the support host events
|
||||
type HostEvent int
|
||||
|
||||
// Supported host events
|
||||
|
|
|
@ -356,23 +356,6 @@ func (d *BackupData) HasFolder(name string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
type keyboardAuthHookRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
Questions []string `json:"questions,omitempty"`
|
||||
}
|
||||
|
||||
type keyboardAuthHookResponse struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Questions []string `json:"questions"`
|
||||
Echos []bool `json:"echos"`
|
||||
AuthResult int `json:"auth_result"`
|
||||
CheckPwd int `json:"check_password"`
|
||||
}
|
||||
|
||||
type checkPasswordRequest struct {
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
|
@ -680,17 +663,15 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
|
|||
return user, loginMethod, fmt.Errorf("certificate login method is not allowed for user %#v", user.Username)
|
||||
}
|
||||
if loginMethod == LoginMethodTLSCertificateAndPwd {
|
||||
if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopePassword) {
|
||||
user, err = doPluginAuth(username, password, nil, ip, protocol, nil, plugin.AuthScopePassword)
|
||||
} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
|
||||
user, err = doExternalAuth(username, password, nil, "", ip, protocol, nil)
|
||||
if err != nil {
|
||||
return user, loginMethod, err
|
||||
}
|
||||
}
|
||||
if config.PreLoginHook != "" {
|
||||
} else if config.PreLoginHook != "" {
|
||||
user, err = executePreLoginHook(username, LoginMethodPassword, ip, protocol)
|
||||
if err != nil {
|
||||
return user, loginMethod, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return user, loginMethod, err
|
||||
}
|
||||
user, err = checkUserAndPass(&user, password, ip, protocol)
|
||||
}
|
||||
|
@ -699,6 +680,9 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
|
|||
|
||||
// CheckUserBeforeTLSAuth checks if a user exits before trying mutual TLS
|
||||
func CheckUserBeforeTLSAuth(username, ip, protocol string, tlsCert *x509.Certificate) (User, error) {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopeTLSCertificate) {
|
||||
return doPluginAuth(username, "", nil, ip, protocol, tlsCert, plugin.AuthScopeTLSCertificate)
|
||||
}
|
||||
if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&8 != 0) {
|
||||
return doExternalAuth(username, "", nil, "", ip, protocol, tlsCert)
|
||||
}
|
||||
|
@ -711,6 +695,13 @@ func CheckUserBeforeTLSAuth(username, ip, protocol string, tlsCert *x509.Certifi
|
|||
// CheckUserAndTLSCert returns the SFTPGo user with the given username and check if the
|
||||
// given TLS certificate allow authentication without password
|
||||
func CheckUserAndTLSCert(username, ip, protocol string, tlsCert *x509.Certificate) (User, error) {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopeTLSCertificate) {
|
||||
user, err := doPluginAuth(username, "", nil, ip, protocol, tlsCert, plugin.AuthScopeTLSCertificate)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndTLSCertificate(&user, protocol, tlsCert)
|
||||
}
|
||||
if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&8 != 0) {
|
||||
user, err := doExternalAuth(username, "", nil, "", ip, protocol, tlsCert)
|
||||
if err != nil {
|
||||
|
@ -730,6 +721,13 @@ func CheckUserAndTLSCert(username, ip, protocol string, tlsCert *x509.Certificat
|
|||
|
||||
// CheckUserAndPass retrieves the SFTPGo user with the given username and password if a match is found or an error
|
||||
func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopePassword) {
|
||||
user, err := doPluginAuth(username, password, nil, ip, protocol, nil, plugin.AuthScopePassword)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndPass(&user, password, ip, protocol)
|
||||
}
|
||||
if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
|
||||
user, err := doExternalAuth(username, password, nil, "", ip, protocol, nil)
|
||||
if err != nil {
|
||||
|
@ -749,6 +747,13 @@ func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
|
|||
|
||||
// CheckUserAndPubKey retrieves the SFTP user with the given username and public key if a match is found or an error
|
||||
func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string) (User, string, error) {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopePublicKey) {
|
||||
user, err := doPluginAuth(username, "", pubKey, ip, protocol, nil, plugin.AuthScopePublicKey)
|
||||
if err != nil {
|
||||
return user, "", err
|
||||
}
|
||||
return checkUserAndPubKey(&user, pubKey)
|
||||
}
|
||||
if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&2 != 0) {
|
||||
user, err := doExternalAuth(username, "", pubKey, "", ip, protocol, nil)
|
||||
if err != nil {
|
||||
|
@ -771,7 +776,9 @@ func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string) (Us
|
|||
func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (User, error) {
|
||||
var user User
|
||||
var err error
|
||||
if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) {
|
||||
user, err = doPluginAuth(username, "", nil, ip, protocol, nil, plugin.AuthScopeKeyboardInteractive)
|
||||
} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
|
||||
user, err = doExternalAuth(username, "", nil, "1", ip, protocol, nil)
|
||||
} else if config.PreLoginHook != "" {
|
||||
user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol)
|
||||
|
@ -1824,51 +1831,79 @@ func terminateInteractiveAuthProgram(cmd *exec.Cmd, isFinished bool) {
|
|||
}
|
||||
}
|
||||
|
||||
func validateKeyboardAuthResponse(response keyboardAuthHookResponse) error {
|
||||
if len(response.Questions) == 0 {
|
||||
err := errors.New("interactive auth error: hook response does not contain questions")
|
||||
providerLog(logger.LevelInfo, "%v", err)
|
||||
return err
|
||||
}
|
||||
if len(response.Questions) != len(response.Echos) {
|
||||
err := fmt.Errorf("interactive auth error, http hook response questions don't match echos: %v %v",
|
||||
len(response.Questions), len(response.Echos))
|
||||
providerLog(logger.LevelInfo, "%v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendKeyboardAuthHTTPReq(url string, request keyboardAuthHookRequest) (keyboardAuthHookResponse, error) {
|
||||
var response keyboardAuthHookResponse
|
||||
func sendKeyboardAuthHTTPReq(url string, request *plugin.KeyboardAuthRequest) (*plugin.KeyboardAuthResponse, error) {
|
||||
reqAsJSON, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error serializing keyboard interactive auth request: %v", err)
|
||||
return response, err
|
||||
return nil, err
|
||||
}
|
||||
resp, err := httpclient.Post(url, "application/json", bytes.NewBuffer(reqAsJSON))
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error getting keyboard interactive auth hook HTTP response: %v", err)
|
||||
return response, err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return response, fmt.Errorf("wrong keyboard interactive auth http status code: %v, expected 200", resp.StatusCode)
|
||||
return nil, fmt.Errorf("wrong keyboard interactive auth http status code: %v, expected 200", resp.StatusCode)
|
||||
}
|
||||
var response plugin.KeyboardAuthResponse
|
||||
err = render.DecodeJSON(resp.Body, &response)
|
||||
return response, err
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func executeKeyboardInteractivePlugin(user *User, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
|
||||
authResult := 0
|
||||
requestID := xid.New().String()
|
||||
authStep := 1
|
||||
req := &plugin.KeyboardAuthRequest{
|
||||
Username: user.Username,
|
||||
IP: ip,
|
||||
Password: user.Password,
|
||||
RequestID: requestID,
|
||||
Step: authStep,
|
||||
}
|
||||
var response *plugin.KeyboardAuthResponse
|
||||
var err error
|
||||
for {
|
||||
response, err = plugin.Handler.ExecuteKeyboardInteractiveStep(req)
|
||||
if err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
if response.AuthResult != 0 {
|
||||
return response.AuthResult, err
|
||||
}
|
||||
if err = response.Validate(); err != nil {
|
||||
providerLog(logger.LevelInfo, "invalid response from keyboard interactive plugin: %v", err)
|
||||
return authResult, err
|
||||
}
|
||||
answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
|
||||
if err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
authStep++
|
||||
req = &plugin.KeyboardAuthRequest{
|
||||
RequestID: requestID,
|
||||
Step: authStep,
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Answers: answers,
|
||||
Questions: response.Questions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func executeKeyboardInteractiveHTTPHook(user *User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
|
||||
authResult := 0
|
||||
requestID := xid.New().String()
|
||||
req := keyboardAuthHookRequest{
|
||||
authStep := 1
|
||||
req := &plugin.KeyboardAuthRequest{
|
||||
Username: user.Username,
|
||||
IP: ip,
|
||||
Password: user.Password,
|
||||
RequestID: requestID,
|
||||
Step: authStep,
|
||||
}
|
||||
var response keyboardAuthHookResponse
|
||||
var response *plugin.KeyboardAuthResponse
|
||||
var err error
|
||||
for {
|
||||
response, err = sendKeyboardAuthHTTPReq(authHook, req)
|
||||
|
@ -1878,15 +1913,18 @@ func executeKeyboardInteractiveHTTPHook(user *User, authHook string, client ssh.
|
|||
if response.AuthResult != 0 {
|
||||
return response.AuthResult, err
|
||||
}
|
||||
if err = validateKeyboardAuthResponse(response); err != nil {
|
||||
if err = response.Validate(); err != nil {
|
||||
providerLog(logger.LevelInfo, "invalid response from keyboard interactive http hook: %v", err)
|
||||
return authResult, err
|
||||
}
|
||||
answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
|
||||
if err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
req = keyboardAuthHookRequest{
|
||||
authStep++
|
||||
req = &plugin.KeyboardAuthRequest{
|
||||
RequestID: requestID,
|
||||
Step: authStep,
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Answers: answers,
|
||||
|
@ -1895,7 +1933,7 @@ func executeKeyboardInteractiveHTTPHook(user *User, authHook string, client ssh.
|
|||
}
|
||||
}
|
||||
|
||||
func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, response keyboardAuthHookResponse,
|
||||
func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, response *plugin.KeyboardAuthResponse,
|
||||
user *User, ip, protocol string) ([]string, error) {
|
||||
questions := response.Questions
|
||||
answers, err := client(user.Username, response.Instruction, questions, response.Echos)
|
||||
|
@ -1920,7 +1958,7 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
|
|||
return answers, err
|
||||
}
|
||||
|
||||
func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, response keyboardAuthHookResponse,
|
||||
func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, response *plugin.KeyboardAuthResponse,
|
||||
user *User, stdin io.WriteCloser, ip, protocol string) error {
|
||||
answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
|
||||
if err != nil {
|
||||
|
@ -1964,7 +2002,7 @@ func executeKeyboardInteractiveProgram(user *User, authHook string, client ssh.K
|
|||
var once sync.Once
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
var response keyboardAuthHookResponse
|
||||
var response plugin.KeyboardAuthResponse
|
||||
err = json.Unmarshal(scanner.Bytes(), &response)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelInfo, "interactive auth error parsing response: %v", err)
|
||||
|
@ -1975,12 +2013,13 @@ func executeKeyboardInteractiveProgram(user *User, authHook string, client ssh.K
|
|||
authResult = response.AuthResult
|
||||
break
|
||||
}
|
||||
if err = validateKeyboardAuthResponse(response); err != nil {
|
||||
if err = response.Validate(); err != nil {
|
||||
providerLog(logger.LevelInfo, "invalid response from keyboard interactive program: %v", err)
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
break
|
||||
}
|
||||
go func() {
|
||||
err := handleProgramInteractiveQuestions(client, response, user, stdin, ip, protocol)
|
||||
err := handleProgramInteractiveQuestions(client, &response, user, stdin, ip, protocol)
|
||||
if err != nil {
|
||||
once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
|
||||
}
|
||||
|
@ -2001,7 +2040,9 @@ func executeKeyboardInteractiveProgram(user *User, authHook string, client ssh.K
|
|||
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") {
|
||||
if plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) {
|
||||
authResult, err = executeKeyboardInteractivePlugin(user, client, ip, protocol)
|
||||
} else if strings.HasPrefix(authHook, "http") {
|
||||
authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip, protocol)
|
||||
} else {
|
||||
authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip, protocol)
|
||||
|
@ -2389,6 +2430,67 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
|
|||
return provider.userExists(user.Username)
|
||||
}
|
||||
|
||||
func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
|
||||
tlsCert *x509.Certificate, authScope int,
|
||||
) (User, error) {
|
||||
var user User
|
||||
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
if u.Filters.Hooks.ExternalAuthDisabled {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
pkey, err := util.GetSSHPublicKeyAsString(pubKey)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
out, err := plugin.Handler.Authenticate(username, password, ip, protocol, pkey, tlsCert, authScope, userAsJSON)
|
||||
if err != nil {
|
||||
return user, fmt.Errorf("plugin auth error for user %#v: %v, elapsed: %v, auth scope: %v",
|
||||
username, err, time.Since(startTime), authScope)
|
||||
}
|
||||
providerLog(logger.LevelDebug, "plugin auth completed for user %#v, elapsed: %v,auth scope: %v",
|
||||
username, time.Since(startTime), authScope)
|
||||
if util.IsByteArrayEmpty(out) {
|
||||
providerLog(logger.LevelDebug, "empty response from plugin auth, no modification requested for user %#v id: %v",
|
||||
username, u.ID)
|
||||
if u.ID == 0 {
|
||||
return u, util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
err = json.Unmarshal(out, &user)
|
||||
if err != nil {
|
||||
return user, fmt.Errorf("invalid plugin auth response: %v", err)
|
||||
}
|
||||
updateUserFromExtAuthResponse(&user, password, pkey)
|
||||
if u.ID > 0 {
|
||||
user.ID = u.ID
|
||||
user.UsedQuotaSize = u.UsedQuotaSize
|
||||
user.UsedQuotaFiles = u.UsedQuotaFiles
|
||||
user.LastQuotaUpdate = u.LastQuotaUpdate
|
||||
user.LastLogin = u.LastLogin
|
||||
err = provider.updateUser(&user)
|
||||
if err == nil {
|
||||
webDAVUsersCache.swap(&user)
|
||||
cachedPasswords.Add(user.Username, password)
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
err = provider.addUser(&user)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return provider.userExists(user.Username)
|
||||
}
|
||||
|
||||
func getUserAndJSONForHook(username string) (User, []byte, error) {
|
||||
var userAsJSON []byte
|
||||
u, err := provider.userExists(username)
|
||||
|
|
|
@ -251,7 +251,7 @@ The configuration file contains the following sections:
|
|||
- `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default empty.
|
||||
- `master_key_path, string. Defines the absolute path to a file containing the master encryption key. Default empty.
|
||||
- **plugins**, list of external plugins. Each plugin is configured using a struct with the following fields:
|
||||
- `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`.
|
||||
- `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`.
|
||||
- `notifier_options`, struct. Defines the options for notifier plugins.
|
||||
- `fs_events`, list of strings. Defines the filesystem events that will be notified to this plugin.
|
||||
- `user_events`, list of strings. Defines the user events that will be notified to this plugin.
|
||||
|
@ -260,6 +260,8 @@ The configuration file contains the following sections:
|
|||
- `kms_options`, struct. Defines the options for kms plugins.
|
||||
- `scheme`, string. KMS scheme. Supported schemes are: `awskms`, `gcpkms`, `hashivault`, `azurekeyvault`.
|
||||
- `encrypted_status`, string. Encrypted status for a KMS secret. Supported statuses are: `AWS`, `GCP`, `VaultTransit`, `AzureKeyVault`.
|
||||
- `auth_options`, struct. Defines the options for auth plugins.
|
||||
- `scope`, integer. 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. 8 means TLS certificate. The flags can be combined, for example 6 means public keys and keyboard interactive. The scope must be explicit, `0` is not a valid option.
|
||||
- `cmd`, string. Path to the plugin executable.
|
||||
- `args`, list of strings. Optional arguments to pass to the plugin executable.
|
||||
- `sha256sum`, string. SHA256 checksum for the plugin executable. If not empty it will be used to verify the integrity of the executable.
|
||||
|
|
|
@ -77,6 +77,7 @@ If the hook is an HTTP URL then it will be invoked as HTTP POST multiple times f
|
|||
The request body will contain a JSON struct with the following fields:
|
||||
|
||||
- `request_id`, string. Unique request identifier
|
||||
- `step`, integer. Counter starting from 1
|
||||
- `username`, string
|
||||
- `ip`, string
|
||||
- `password`, string. This is the hashed password as stored inside the data provider
|
||||
|
@ -95,7 +96,7 @@ Content-Length: 189
|
|||
Content-Type: application/json
|
||||
Accept-Encoding: gzip
|
||||
|
||||
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA=="}
|
||||
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","step":1,"password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA=="}
|
||||
```
|
||||
|
||||
as you can see in this first requests `answers` and `questions` are null.
|
||||
|
@ -123,7 +124,7 @@ Content-Length: 233
|
|||
Content-Type: application/json
|
||||
Accept-Encoding: gzip
|
||||
|
||||
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["OK"],"questions":["Password: "]}
|
||||
{"request_id":"bq1r5r7cdrpd2qtn25ng","step":2,"username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["OK"],"questions":["Password: "]}
|
||||
```
|
||||
|
||||
Here is the HTTP response that instructs SFTPGo to ask for a new question:
|
||||
|
@ -149,7 +150,7 @@ Content-Length: 239
|
|||
Content-Type: application/json
|
||||
Accept-Encoding: gzip
|
||||
|
||||
{"request_id":"bq1r5r7cdrpd2qtn25ng","username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["answer2"],"questions":["Question2: "]}
|
||||
{"request_id":"bq1r5r7cdrpd2qtn25ng","step":3,"username":"a","ip":"127.0.0.1","password":"$pbkdf2-sha512$150000$ClOPkLNujMTL$XktKy0xuJsOfMYBz+f2bIyPTdbvDTSnJ1q+7+zp/HPq5Qojwp6kcpSIiVHiwvbi8P6HFXI/D3UJv9BLcnQFqPA==","answers":["answer2"],"questions":["Question2: "]}
|
||||
```
|
||||
|
||||
Here is the final HTTP response that allows the user login:
|
||||
|
|
|
@ -8,6 +8,12 @@ The plugins are configured via the `plugins` section in the main SFTPGo configur
|
|||
|
||||
For added security you can enable the automatic TLS. In this way, the client and the server automatically negotiate mutual TLS for transport authentication. This ensures that only the original client will be allowed to connect to the server, and all other connections will be rejected. The client will also refuse to connect to any server that isn't the original instance started by the client.
|
||||
|
||||
The following plugin types are supported:
|
||||
|
||||
- `auth`, allows to authenticate users
|
||||
- `notifier`, allows to receive notifications for supported filesystem events such as file uploads, downloads etc. and user events such as add, update, delete.
|
||||
- `kms`, allows to support additional KMS providers.
|
||||
|
||||
Full configuration details can be found [here](./full-configuration.md)
|
||||
|
||||
## Available plugins
|
||||
|
|
180
sdk/plugin/auth.go
Normal file
180
sdk/plugin/auth.go
Normal file
|
@ -0,0 +1,180 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-plugin"
|
||||
|
||||
"github.com/drakkan/sftpgo/v2/logger"
|
||||
"github.com/drakkan/sftpgo/v2/sdk/plugin/auth"
|
||||
)
|
||||
|
||||
// Supported auth scopes
|
||||
const (
|
||||
AuthScopePassword = 1
|
||||
AuthScopePublicKey = 2
|
||||
AuthScopeKeyboardInteractive = 4
|
||||
AuthScopeTLSCertificate = 8
|
||||
)
|
||||
|
||||
// KeyboardAuthRequest defines the request for a keyboard interactive authentication step
|
||||
type KeyboardAuthRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Step int `json:"step"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
Questions []string `json:"questions,omitempty"`
|
||||
}
|
||||
|
||||
// KeyboardAuthResponse defines the response for a keyboard interactive authentication step
|
||||
type KeyboardAuthResponse struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Questions []string `json:"questions"`
|
||||
Echos []bool `json:"echos"`
|
||||
AuthResult int `json:"auth_result"`
|
||||
CheckPwd int `json:"check_password"`
|
||||
}
|
||||
|
||||
// Validate returns an error if the KeyboardAuthResponse is invalid
|
||||
func (r *KeyboardAuthResponse) Validate() error {
|
||||
if len(r.Questions) == 0 {
|
||||
err := errors.New("interactive auth error: response does not contain questions")
|
||||
return err
|
||||
}
|
||||
if len(r.Questions) != len(r.Echos) {
|
||||
err := fmt.Errorf("interactive auth error: response questions don't match echos: %v %v",
|
||||
len(r.Questions), len(r.Echos))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthConfig defines configuration parameters for auth plugins
|
||||
type AuthConfig struct {
|
||||
// Scope defines the scope for the authentication plugin.
|
||||
// - 1 means passwords only
|
||||
// - 2 means public keys only
|
||||
// - 4 means keyboard interactive only
|
||||
// - 8 means TLS certificates only
|
||||
// you can combine the scopes, for example 3 means password and public key, 5 password and keyboard
|
||||
// interactive and so on
|
||||
Scope int `json:"scope" mapstructure:"scope"`
|
||||
}
|
||||
|
||||
func (c *AuthConfig) validate() error {
|
||||
authScopeMax := AuthScopePassword + AuthScopePublicKey + AuthScopeKeyboardInteractive + AuthScopeTLSCertificate
|
||||
if c.Scope == 0 || c.Scope > authScopeMax {
|
||||
return fmt.Errorf("invalid auth scope: %v", c.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type authPlugin struct {
|
||||
config Config
|
||||
service auth.Authenticator
|
||||
client *plugin.Client
|
||||
}
|
||||
|
||||
func newAuthPlugin(config Config) (*authPlugin, error) {
|
||||
p := &authPlugin{
|
||||
config: config,
|
||||
}
|
||||
if err := p.initialize(); err != nil {
|
||||
logger.Warn(logSender, "", "unable to create auth plugin: %v, config %+v", err, config)
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *authPlugin) initialize() error {
|
||||
killProcess(p.config.Cmd)
|
||||
logger.Debug(logSender, "", "create new auth plugin %#v", p.config.Cmd)
|
||||
if err := p.config.AuthOptions.validate(); err != nil {
|
||||
return fmt.Errorf("invalid options for auth plugin %#v: %v", p.config.Cmd, err)
|
||||
}
|
||||
|
||||
var secureConfig *plugin.SecureConfig
|
||||
if p.config.SHA256Sum != "" {
|
||||
secureConfig.Checksum = []byte(p.config.SHA256Sum)
|
||||
secureConfig.Hash = sha256.New()
|
||||
}
|
||||
client := plugin.NewClient(&plugin.ClientConfig{
|
||||
HandshakeConfig: auth.Handshake,
|
||||
Plugins: auth.PluginMap,
|
||||
Cmd: exec.Command(p.config.Cmd, p.config.Args...),
|
||||
AllowedProtocols: []plugin.Protocol{
|
||||
plugin.ProtocolGRPC,
|
||||
},
|
||||
AutoMTLS: p.config.AutoMTLS,
|
||||
SecureConfig: secureConfig,
|
||||
Managed: false,
|
||||
Logger: &logger.HCLogAdapter{
|
||||
Logger: hclog.New(&hclog.LoggerOptions{
|
||||
Name: fmt.Sprintf("%v.%v", logSender, auth.PluginName),
|
||||
Level: pluginsLogLevel,
|
||||
DisableTime: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
rpcClient, err := client.Client()
|
||||
if err != nil {
|
||||
logger.Debug(logSender, "", "unable to get rpc client for kms plugin %#v: %v", p.config.Cmd, err)
|
||||
return err
|
||||
}
|
||||
raw, err := rpcClient.Dispense(auth.PluginName)
|
||||
if err != nil {
|
||||
logger.Debug(logSender, "", "unable to get plugin %v from rpc client for command %#v: %v",
|
||||
auth.PluginName, p.config.Cmd, err)
|
||||
return err
|
||||
}
|
||||
|
||||
p.service = raw.(auth.Authenticator)
|
||||
p.client = client
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *authPlugin) exited() bool {
|
||||
return p.client.Exited()
|
||||
}
|
||||
|
||||
func (p *authPlugin) cleanup() {
|
||||
p.client.Kill()
|
||||
}
|
||||
|
||||
func (p *authPlugin) checkUserAndPass(username, password, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
return p.service.CheckUserAndPass(username, password, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (p *authPlugin) checkUserAndTLSCertificate(username, tlsCert, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
return p.service.CheckUserAndTLSCert(username, tlsCert, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (p *authPlugin) checkUserAndPublicKey(username, pubKey, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
return p.service.CheckUserAndPublicKey(username, pubKey, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (p *authPlugin) checkUserAndKeyboardInteractive(username, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
return p.service.CheckUserAndKeyboardInteractive(username, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (p *authPlugin) sendKeyboardIteractiveRequest(req *KeyboardAuthRequest) (*KeyboardAuthResponse, error) {
|
||||
instructions, questions, echos, authResult, checkPassword, err := p.service.SendKeyboardAuthRequest(
|
||||
req.RequestID, req.Username, req.Password, req.IP, req.Answers, req.Questions, int32(req.Step))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &KeyboardAuthResponse{
|
||||
Instruction: instructions,
|
||||
Questions: questions,
|
||||
Echos: echos,
|
||||
AuthResult: authResult,
|
||||
CheckPwd: checkPassword,
|
||||
}, nil
|
||||
}
|
59
sdk/plugin/auth/auth.go
Normal file
59
sdk/plugin/auth/auth.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
// Package auth defines the implementation for authentication plugins.
|
||||
// Authentication plugins allow to authenticate external users
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/go-plugin"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/drakkan/sftpgo/v2/sdk/plugin/auth/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// PluginName defines the name for a notifier plugin
|
||||
PluginName = "auth"
|
||||
)
|
||||
|
||||
// Handshake is a common handshake that is shared by plugin and host.
|
||||
var Handshake = plugin.HandshakeConfig{
|
||||
ProtocolVersion: 1,
|
||||
MagicCookieKey: "SFTPGO_AUTH_PLUGIN",
|
||||
MagicCookieValue: "d1ed507d-d2be-4a38-a460-6fe0b2cc7efc",
|
||||
}
|
||||
|
||||
// PluginMap is the map of plugins we can dispense.
|
||||
var PluginMap = map[string]plugin.Plugin{
|
||||
PluginName: &Plugin{},
|
||||
}
|
||||
|
||||
// Authenticator defines the interface for authentication plugins
|
||||
type Authenticator interface {
|
||||
CheckUserAndPass(username, password, ip, protocol string, userAsJSON []byte) ([]byte, error)
|
||||
CheckUserAndTLSCert(username, tlsCert, ip, protocol string, userAsJSON []byte) ([]byte, error)
|
||||
CheckUserAndPublicKey(username, pubKey, ip, protocol string, userAsJSON []byte) ([]byte, error)
|
||||
CheckUserAndKeyboardInteractive(username, ip, protocol string, userAsJSON []byte) ([]byte, error)
|
||||
SendKeyboardAuthRequest(requestID, username, password, ip string, answers, questions []string, step int32) (string, []string, []bool, int, int, error)
|
||||
}
|
||||
|
||||
// Plugin defines the implementation to serve/connect to an authe plugin
|
||||
type Plugin struct {
|
||||
plugin.Plugin
|
||||
Impl Authenticator
|
||||
}
|
||||
|
||||
// GRPCServer defines the GRPC server implementation for this plugin
|
||||
func (p *Plugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
|
||||
proto.RegisterAuthServer(s, &GRPCServer{
|
||||
Impl: p.Impl,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GRPCClient defines the GRPC client implementation for this plugin
|
||||
func (p *Plugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
|
||||
return &GRPCClient{
|
||||
client: proto.NewAuthClient(c),
|
||||
}, nil
|
||||
}
|
165
sdk/plugin/auth/grpc.go
Normal file
165
sdk/plugin/auth/grpc.go
Normal file
|
@ -0,0 +1,165 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/v2/sdk/plugin/auth/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
rpcTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// GRPCClient is an implementation of Authenticator interface that talks over RPC.
|
||||
type GRPCClient struct {
|
||||
client proto.AuthClient
|
||||
}
|
||||
|
||||
// CheckUserAndPass implements the Authenticator interface
|
||||
func (c *GRPCClient) CheckUserAndPass(username, password, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.CheckUserAndPass(ctx, &proto.CheckUserAndPassRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Ip: ip,
|
||||
Protocol: protocol,
|
||||
User: userAsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.User, nil
|
||||
}
|
||||
|
||||
// CheckUserAndTLSCert implements the Authenticator interface
|
||||
func (c *GRPCClient) CheckUserAndTLSCert(username, tlsCert, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.CheckUserAndTLSCert(ctx, &proto.CheckUserAndTLSCertRequest{
|
||||
Username: username,
|
||||
TlsCert: tlsCert,
|
||||
Ip: ip,
|
||||
Protocol: protocol,
|
||||
User: userAsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.User, nil
|
||||
}
|
||||
|
||||
// CheckUserAndPublicKey implements the Authenticator interface
|
||||
func (c *GRPCClient) CheckUserAndPublicKey(username, pubKey, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.CheckUserAndPublicKey(ctx, &proto.CheckUserAndPublicKeyRequest{
|
||||
Username: username,
|
||||
PubKey: pubKey,
|
||||
Ip: ip,
|
||||
Protocol: protocol,
|
||||
User: userAsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.User, nil
|
||||
}
|
||||
|
||||
// CheckUserAndKeyboardInteractive implements the Authenticator interface
|
||||
func (c *GRPCClient) CheckUserAndKeyboardInteractive(username, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.CheckUserAndKeyboardInteractive(ctx, &proto.CheckUserAndKeyboardInteractiveRequest{
|
||||
Username: username,
|
||||
Ip: ip,
|
||||
Protocol: protocol,
|
||||
User: userAsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.User, nil
|
||||
}
|
||||
|
||||
// SendKeyboardAuthRequest implements the Authenticator interface
|
||||
func (c *GRPCClient) SendKeyboardAuthRequest(requestID, username, password, ip string, answers, questions []string, step int32) (string, []string, []bool, int, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.SendKeyboardAuthRequest(ctx, &proto.KeyboardAuthRequest{
|
||||
RequestID: requestID,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Ip: ip,
|
||||
Answers: answers,
|
||||
Questions: questions,
|
||||
Step: step,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, nil, 0, 0, err
|
||||
}
|
||||
return resp.Instructions, resp.Questions, resp.Echos, int(resp.AuthResult), int(resp.CheckPassword), err
|
||||
}
|
||||
|
||||
// GRPCServer defines the gRPC server that GRPCClient talks to.
|
||||
type GRPCServer struct {
|
||||
Impl Authenticator
|
||||
}
|
||||
|
||||
// CheckUserAndPass implements the server side check user and password method
|
||||
func (s *GRPCServer) CheckUserAndPass(ctx context.Context, req *proto.CheckUserAndPassRequest) (*proto.AuthResponse, error) {
|
||||
user, err := s.Impl.CheckUserAndPass(req.Username, req.Password, req.Ip, req.Protocol, req.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.AuthResponse{User: user}, nil
|
||||
}
|
||||
|
||||
// CheckUserAndTLSCert implements the server side check user and tls certificate method
|
||||
func (s *GRPCServer) CheckUserAndTLSCert(ctx context.Context, req *proto.CheckUserAndTLSCertRequest) (*proto.AuthResponse, error) {
|
||||
user, err := s.Impl.CheckUserAndTLSCert(req.Username, req.TlsCert, req.Ip, req.Protocol, req.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.AuthResponse{User: user}, nil
|
||||
}
|
||||
|
||||
// CheckUserAndPublicKey implements the server side check user and public key method
|
||||
func (s *GRPCServer) CheckUserAndPublicKey(ctx context.Context, req *proto.CheckUserAndPublicKeyRequest) (*proto.AuthResponse, error) {
|
||||
user, err := s.Impl.CheckUserAndPublicKey(req.Username, req.PubKey, req.Ip, req.Protocol, req.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.AuthResponse{User: user}, nil
|
||||
}
|
||||
|
||||
// CheckUserAndKeyboardInteractive implements the server side check user and keyboard interactive method
|
||||
func (s *GRPCServer) CheckUserAndKeyboardInteractive(ctx context.Context, req *proto.CheckUserAndKeyboardInteractiveRequest) (*proto.AuthResponse, error) {
|
||||
user, err := s.Impl.CheckUserAndKeyboardInteractive(req.Username, req.Ip, req.Protocol, req.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.AuthResponse{User: user}, nil
|
||||
}
|
||||
|
||||
// SendKeyboardAuthRequest implements the server side method to send a keyboard interactive authentication request
|
||||
func (s *GRPCServer) SendKeyboardAuthRequest(ctx context.Context, req *proto.KeyboardAuthRequest) (*proto.KeyboardAuthResponse, error) {
|
||||
instructions, questions, echos, authResult, checkPwd, err := s.Impl.SendKeyboardAuthRequest(req.RequestID, req.Username,
|
||||
req.Password, req.Ip, req.Answers, req.Questions, req.Step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.KeyboardAuthResponse{
|
||||
Instructions: instructions,
|
||||
Questions: questions,
|
||||
Echos: echos,
|
||||
AuthResult: int32(authResult),
|
||||
CheckPassword: int32(checkPwd),
|
||||
}, nil
|
||||
}
|
1028
sdk/plugin/auth/proto/auth.pb.go
Normal file
1028
sdk/plugin/auth/proto/auth.pb.go
Normal file
File diff suppressed because it is too large
Load diff
65
sdk/plugin/auth/proto/auth.proto
Normal file
65
sdk/plugin/auth/proto/auth.proto
Normal file
|
@ -0,0 +1,65 @@
|
|||
syntax = "proto3";
|
||||
package proto;
|
||||
|
||||
option go_package = "sdk/plugin/auth/proto";
|
||||
|
||||
message CheckUserAndPassRequest {
|
||||
string username = 1;
|
||||
string password = 2;
|
||||
string ip = 3;
|
||||
string protocol = 4;
|
||||
bytes user = 5; // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
message CheckUserAndTLSCertRequest {
|
||||
string username = 1;
|
||||
string tlsCert = 2; // tls certificate pem encoded
|
||||
string ip = 3;
|
||||
string protocol = 4;
|
||||
bytes user = 5; // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
message CheckUserAndPublicKeyRequest {
|
||||
string username = 1;
|
||||
string pubKey = 2;
|
||||
string ip = 3;
|
||||
string protocol = 4;
|
||||
bytes user = 5; // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
message CheckUserAndKeyboardInteractiveRequest {
|
||||
string username = 1;
|
||||
string ip = 2;
|
||||
string protocol = 3;
|
||||
bytes user = 4; // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
message KeyboardAuthRequest {
|
||||
string requestID = 1;
|
||||
string username = 2;
|
||||
string password = 3;
|
||||
string ip = 4;
|
||||
repeated string answers = 5;
|
||||
repeated string questions = 6;
|
||||
int32 step = 7;
|
||||
}
|
||||
|
||||
message KeyboardAuthResponse {
|
||||
string instructions = 1;
|
||||
repeated string questions = 2;
|
||||
repeated bool echos = 3;
|
||||
int32 auth_result = 4;
|
||||
int32 check_password = 5;
|
||||
}
|
||||
|
||||
message AuthResponse {
|
||||
bytes user = 1; // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
service Auth {
|
||||
rpc CheckUserAndPass(CheckUserAndPassRequest) returns (AuthResponse);
|
||||
rpc CheckUserAndTLSCert(CheckUserAndTLSCertRequest) returns (AuthResponse);
|
||||
rpc CheckUserAndPublicKey(CheckUserAndPublicKeyRequest) returns (AuthResponse);
|
||||
rpc CheckUserAndKeyboardInteractive(CheckUserAndKeyboardInteractiveRequest) returns (AuthResponse);
|
||||
rpc SendKeyboardAuthRequest(KeyboardAuthRequest) returns (KeyboardAuthResponse);
|
||||
}
|
|
@ -27,7 +27,7 @@ type KMSConfig struct {
|
|||
EncryptedStatus string `json:"encrypted_status" mapstructure:"encrypted_status"`
|
||||
}
|
||||
|
||||
func (c *KMSConfig) isValid() error {
|
||||
func (c *KMSConfig) validate() error {
|
||||
if !util.IsStringInSlice(c.Scheme, validKMSSchemes) {
|
||||
return fmt.Errorf("invalid kms scheme: %v", c.Scheme)
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ func newKMSPlugin(config Config) (*kmsPlugin, error) {
|
|||
func (p *kmsPlugin) initialize() error {
|
||||
killProcess(p.config.Cmd)
|
||||
logger.Debug(logSender, "", "create new kms plugin %#v", p.config.Cmd)
|
||||
if err := p.config.KMSOptions.isValid(); err != nil {
|
||||
if err := p.config.KMSOptions.validate(); err != nil {
|
||||
return fmt.Errorf("invalid options for kms plugin %#v: %v", p.config.Cmd, err)
|
||||
}
|
||||
var secureConfig *plugin.SecureConfig
|
||||
|
|
|
@ -2,5 +2,6 @@
|
|||
|
||||
protoc notifier/proto/notifier.proto --go_out=plugins=grpc:../.. --go_out=../../..
|
||||
protoc kms/proto/kms.proto --go_out=plugins=grpc:../.. --go_out=../../..
|
||||
protoc auth/proto/auth.proto --go_out=plugins=grpc:../.. --go_out=../../..
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Package notifier defines the implementation for event notifier plugins.
|
||||
// Notifier plugins allow to receive filesystem events such as file uploads,
|
||||
// downloads etc. and user events such as add, update, delete.
|
||||
// Notifier plugins allow to receive notifications for supported filesystem
|
||||
// events such as file uploads, downloads etc. and user events such as add,
|
||||
// update, delete.
|
||||
package notifier
|
||||
|
||||
import (
|
||||
|
|
|
@ -144,7 +144,7 @@ type UserEvent struct {
|
|||
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
Action string `protobuf:"bytes,2,opt,name=action,proto3" json:"action,omitempty"`
|
||||
User []byte `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"` // SFTPGo user json serialized
|
||||
User []byte `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"` // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
func (x *UserEvent) Reset() {
|
||||
|
|
|
@ -21,7 +21,7 @@ message FsEvent {
|
|||
message UserEvent {
|
||||
google.protobuf.Timestamp timestamp = 1;
|
||||
string action = 2;
|
||||
bytes user = 3; // SFTPGo user json serialized
|
||||
bytes user = 3; // SFTPGo user JSON serialized
|
||||
}
|
||||
|
||||
service Notifier {
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
@ -11,8 +13,10 @@ import (
|
|||
|
||||
"github.com/drakkan/sftpgo/v2/kms"
|
||||
"github.com/drakkan/sftpgo/v2/logger"
|
||||
"github.com/drakkan/sftpgo/v2/sdk/plugin/auth"
|
||||
kmsplugin "github.com/drakkan/sftpgo/v2/sdk/plugin/kms"
|
||||
"github.com/drakkan/sftpgo/v2/sdk/plugin/notifier"
|
||||
"github.com/drakkan/sftpgo/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -38,6 +42,8 @@ type Config struct {
|
|||
NotifierOptions NotifierConfig `json:"notifier_options" mapstructure:"notifier_options"`
|
||||
// KMSOptions defines options for a KMS plugin
|
||||
KMSOptions KMSConfig `json:"kms_options" mapstructure:"kms_options"`
|
||||
// AuthOptions defines options for authentication plugins
|
||||
AuthOptions AuthConfig `json:"auth_options" mapstructure:"auth_options"`
|
||||
// Path to the plugin executable
|
||||
Cmd string `json:"cmd" mapstructure:"cmd"`
|
||||
// Args to pass to the plugin executable
|
||||
|
@ -69,19 +75,23 @@ type Manager struct {
|
|||
closed int32
|
||||
done chan bool
|
||||
// List of configured plugins
|
||||
Configs []Config `json:"plugins" mapstructure:"plugins"`
|
||||
notifLock sync.RWMutex
|
||||
notifiers []*notifierPlugin
|
||||
kmsLock sync.RWMutex
|
||||
kms []*kmsPlugin
|
||||
Configs []Config `json:"plugins" mapstructure:"plugins"`
|
||||
notifLock sync.RWMutex
|
||||
notifiers []*notifierPlugin
|
||||
kmsLock sync.RWMutex
|
||||
kms []*kmsPlugin
|
||||
authLock sync.RWMutex
|
||||
auths []*authPlugin
|
||||
authScopes int
|
||||
}
|
||||
|
||||
// Initialize initializes the configured plugins
|
||||
func Initialize(configs []Config, logVerbose bool) error {
|
||||
Handler = Manager{
|
||||
Configs: configs,
|
||||
done: make(chan bool),
|
||||
closed: 0,
|
||||
Configs: configs,
|
||||
done: make(chan bool),
|
||||
closed: 0,
|
||||
authScopes: -1,
|
||||
}
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
|
@ -117,6 +127,17 @@ func Initialize(configs []Config, logVerbose bool) error {
|
|||
Handler.Configs[idx].newKMSPluginSecretProvider)
|
||||
logger.Debug(logSender, "", "registered secret provider for scheme: %v, encrypted status: %v",
|
||||
config.KMSOptions.Scheme, config.KMSOptions.EncryptedStatus)
|
||||
case auth.PluginName:
|
||||
plugin, err := newAuthPlugin(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Handler.auths = append(Handler.auths, plugin)
|
||||
if Handler.authScopes == -1 {
|
||||
Handler.authScopes = config.AuthOptions.Scope
|
||||
} else {
|
||||
Handler.authScopes |= config.AuthOptions.Scope
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported plugin type: %v", config.Type)
|
||||
}
|
||||
|
@ -181,6 +202,133 @@ func (m *Manager) kmsDecrypt(secret kms.BaseSecret, url string, masterKey string
|
|||
return plugin.Decrypt(secret, url, masterKey)
|
||||
}
|
||||
|
||||
// HasAuthScope returns true if there is an auth plugin that support the specified scope
|
||||
func (m *Manager) HasAuthScope(scope int) bool {
|
||||
if m.authScopes == -1 {
|
||||
return false
|
||||
}
|
||||
return m.authScopes&scope != 0
|
||||
}
|
||||
|
||||
// Authenticate tries to authenticate the specified user using an external plugin
|
||||
func (m *Manager) Authenticate(username, password, ip, protocol string, pkey string,
|
||||
tlsCert *x509.Certificate, authScope int, userAsJSON []byte,
|
||||
) ([]byte, error) {
|
||||
switch authScope {
|
||||
case AuthScopePassword:
|
||||
return m.checkUserAndPass(username, password, ip, protocol, userAsJSON)
|
||||
case AuthScopePublicKey:
|
||||
return m.checkUserAndPublicKey(username, pkey, ip, protocol, userAsJSON)
|
||||
case AuthScopeKeyboardInteractive:
|
||||
return m.checkUserAndKeyboardInteractive(username, ip, protocol, userAsJSON)
|
||||
case AuthScopeTLSCertificate:
|
||||
cert, err := util.EncodeTLSCertToPem(tlsCert)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "unable to encode tls certificate to pem: %v", err)
|
||||
return nil, fmt.Errorf("unable to encode tls cert to pem: %w", err)
|
||||
}
|
||||
return m.checkUserAndTLSCert(username, cert, ip, protocol, userAsJSON)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported auth scope: %v", authScope)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteKeyboardInteractiveStep executes a keyboard interactive step
|
||||
func (m *Manager) ExecuteKeyboardInteractiveStep(req *KeyboardAuthRequest) (*KeyboardAuthResponse, error) {
|
||||
var plugin *authPlugin
|
||||
|
||||
m.authLock.Lock()
|
||||
for _, p := range m.auths {
|
||||
if p.config.AuthOptions.Scope&AuthScopePassword != 0 {
|
||||
plugin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
m.authLock.Unlock()
|
||||
|
||||
if plugin == nil {
|
||||
return nil, errors.New("no auth plugin configured for keyaboard interactive authentication step")
|
||||
}
|
||||
|
||||
return plugin.sendKeyboardIteractiveRequest(req)
|
||||
}
|
||||
|
||||
func (m *Manager) checkUserAndPass(username, password, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
var plugin *authPlugin
|
||||
|
||||
m.authLock.Lock()
|
||||
for _, p := range m.auths {
|
||||
if p.config.AuthOptions.Scope&AuthScopePassword != 0 {
|
||||
plugin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
m.authLock.Unlock()
|
||||
|
||||
if plugin == nil {
|
||||
return nil, errors.New("no auth plugin configured for password checking")
|
||||
}
|
||||
|
||||
return plugin.checkUserAndPass(username, password, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (m *Manager) checkUserAndPublicKey(username, pubKey, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
var plugin *authPlugin
|
||||
|
||||
m.authLock.Lock()
|
||||
for _, p := range m.auths {
|
||||
if p.config.AuthOptions.Scope&AuthScopePublicKey != 0 {
|
||||
plugin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
m.authLock.Unlock()
|
||||
|
||||
if plugin == nil {
|
||||
return nil, errors.New("no auth plugin configured for public key checking")
|
||||
}
|
||||
|
||||
return plugin.checkUserAndPublicKey(username, pubKey, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (m *Manager) checkUserAndTLSCert(username, tlsCert, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
var plugin *authPlugin
|
||||
|
||||
m.authLock.Lock()
|
||||
for _, p := range m.auths {
|
||||
if p.config.AuthOptions.Scope&AuthScopeTLSCertificate != 0 {
|
||||
plugin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
m.authLock.Unlock()
|
||||
|
||||
if plugin == nil {
|
||||
return nil, errors.New("no auth plugin configured for TLS certificate checking")
|
||||
}
|
||||
|
||||
return plugin.checkUserAndTLSCertificate(username, tlsCert, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (m *Manager) checkUserAndKeyboardInteractive(username, ip, protocol string, userAsJSON []byte) ([]byte, error) {
|
||||
var plugin *authPlugin
|
||||
|
||||
m.authLock.Lock()
|
||||
for _, p := range m.auths {
|
||||
if p.config.AuthOptions.Scope&AuthScopeKeyboardInteractive != 0 {
|
||||
plugin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
m.authLock.Unlock()
|
||||
|
||||
if plugin == nil {
|
||||
return nil, errors.New("no auth plugin configured for keyboard interactive checking")
|
||||
}
|
||||
|
||||
return plugin.checkUserAndKeyboardInteractive(username, ip, protocol, userAsJSON)
|
||||
}
|
||||
|
||||
func (m *Manager) checkCrashedPlugins() {
|
||||
m.notifLock.RLock()
|
||||
for idx, n := range m.notifiers {
|
||||
|
@ -203,6 +351,16 @@ func (m *Manager) checkCrashedPlugins() {
|
|||
}
|
||||
}
|
||||
m.kmsLock.RUnlock()
|
||||
|
||||
m.authLock.RLock()
|
||||
for idx, a := range m.auths {
|
||||
if a.exited() {
|
||||
defer func(cfg Config, index int) {
|
||||
Handler.restartAuthPlugin(cfg, index)
|
||||
}(a.config, idx)
|
||||
}
|
||||
}
|
||||
m.authLock.RUnlock()
|
||||
}
|
||||
|
||||
func (m *Manager) restartNotifierPlugin(config Config, idx int) {
|
||||
|
@ -239,6 +397,22 @@ func (m *Manager) restartKMSPlugin(config Config, idx int) {
|
|||
m.kmsLock.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) restartAuthPlugin(config Config, idx int) {
|
||||
if atomic.LoadInt32(&m.closed) == 1 {
|
||||
return
|
||||
}
|
||||
logger.Info(logSender, "", "try to restart crashed auth plugin %#v, idx: %v", config.Cmd, idx)
|
||||
plugin, err := newAuthPlugin(config)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "unable to restart auth plugin %#v, err: %v", config.Cmd, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.authLock.Lock()
|
||||
m.auths[idx] = plugin
|
||||
m.authLock.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup releases all the active plugins
|
||||
func (m *Manager) Cleanup() {
|
||||
atomic.StoreInt32(&m.closed, 1)
|
||||
|
@ -256,6 +430,13 @@ func (m *Manager) Cleanup() {
|
|||
k.cleanup()
|
||||
}
|
||||
m.kmsLock.Unlock()
|
||||
|
||||
m.authLock.Lock()
|
||||
for _, a := range m.auths {
|
||||
logger.Debug(logSender, "", "cleanup auth plugin %v", a.config.Cmd)
|
||||
a.cleanup()
|
||||
}
|
||||
m.authLock.Unlock()
|
||||
}
|
||||
|
||||
func startCheckTicker() {
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/v2/dataprovider"
|
||||
"github.com/drakkan/sftpgo/v2/logger"
|
||||
"github.com/drakkan/sftpgo/v2/metric"
|
||||
"github.com/drakkan/sftpgo/v2/sdk/plugin"
|
||||
"github.com/drakkan/sftpgo/v2/util"
|
||||
"github.com/drakkan/sftpgo/v2/vfs"
|
||||
)
|
||||
|
@ -306,22 +307,24 @@ func (c *Configuration) configureLoginBanner(serverConfig *ssh.ServerConfig, con
|
|||
}
|
||||
|
||||
func (c *Configuration) configureKeyboardInteractiveAuth(serverConfig *ssh.ServerConfig) {
|
||||
if c.KeyboardInteractiveHook == "" {
|
||||
if c.KeyboardInteractiveHook == "" && !plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.KeyboardInteractiveHook, "http") {
|
||||
if !filepath.IsAbs(c.KeyboardInteractiveHook) {
|
||||
logger.WarnToConsole("invalid keyboard interactive authentication program: %#v must be an absolute path",
|
||||
c.KeyboardInteractiveHook)
|
||||
logger.Warn(logSender, "", "invalid keyboard interactive authentication program: %#v must be an absolute path",
|
||||
c.KeyboardInteractiveHook)
|
||||
return
|
||||
}
|
||||
_, err := os.Stat(c.KeyboardInteractiveHook)
|
||||
if err != nil {
|
||||
logger.WarnToConsole("invalid keyboard interactive authentication program:: %v", err)
|
||||
logger.Warn(logSender, "", "invalid keyboard interactive authentication program:: %v", err)
|
||||
return
|
||||
if c.KeyboardInteractiveHook != "" {
|
||||
if !strings.HasPrefix(c.KeyboardInteractiveHook, "http") {
|
||||
if !filepath.IsAbs(c.KeyboardInteractiveHook) {
|
||||
logger.WarnToConsole("invalid keyboard interactive authentication program: %#v must be an absolute path",
|
||||
c.KeyboardInteractiveHook)
|
||||
logger.Warn(logSender, "", "invalid keyboard interactive authentication program: %#v must be an absolute path",
|
||||
c.KeyboardInteractiveHook)
|
||||
return
|
||||
}
|
||||
_, err := os.Stat(c.KeyboardInteractiveHook)
|
||||
if err != nil {
|
||||
logger.WarnToConsole("invalid keyboard interactive authentication program:: %v", err)
|
||||
logger.Warn(logSender, "", "invalid keyboard interactive authentication program:: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
serverConfig.KeyboardInteractiveCallback = func(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
|
||||
|
|
Loading…
Reference in a new issue