diff --git a/docs/keyboard-interactive.md b/docs/keyboard-interactive.md index b1c56c4d..89e0beda 100644 --- a/docs/keyboard-interactive.md +++ b/docs/keyboard-interactive.md @@ -164,3 +164,5 @@ Content-Length: 18 {"auth_result": 1} ``` + +An example keyboard interactive program allowing to authenticate using [Twillo Authy 2FA](https://www.twilio.com/docs/authy) can be found inside the source tree [authy](../examples/OTP/authy) directory. diff --git a/examples/OTP/authy/README.md b/examples/OTP/authy/README.md new file mode 100644 index 00000000..e9e77bd6 --- /dev/null +++ b/examples/OTP/authy/README.md @@ -0,0 +1,57 @@ +# Authy + +These example show how-to integrate [Twillo Authy API](https://www.twilio.com/docs/authy/api) for One-Time-Password logins. + +The examples assume that the user has the free [Authy app](https://authy.com/) installed and uses it to generate offline [TOTP](https://en.wikipedia.org/wiki/Time-based_One-time_Password_algorithm) codes (soft tokens). + +You first need to [create an Authy Application in the Twilio Console](https://twilio.com/console/authy/applications?_ga=2.205553366.451688189.1597667213-1526360003.1597667213), then you can create a new Authy user and store a reference to the matching SFTPGo account. + +Verify that your Authy application is successfully registered: + +```bash +export AUTHY_API_KEY= +curl 'https://api.authy.com/protected/json/app/details' -H "X-Authy-API-Key: $AUTHY_API_KEY" +``` + +now create an Authy user: + +```bash +curl -XPOST "https://api.authy.com/protected/json/users/new" \ +-H "X-Authy-API-Key: $AUTHY_API_KEY" \ +--data-urlencode user[email]="user@domain.com" \ +--data-urlencode user[cellphone]="317-338-9302" \ +--data-urlencode user[country_code]="54" +``` + +The response is something like this: + +```json +{"message":"User created successfully.","user":{"id":xxxxxxxx},"success":true} +``` + +Save the user id somewhere and add a reference to the matching SFTPGo account. + +After this step you can use the Authy app installed on your phone to generate TOTP codes. + +Now you can verify the token using an HTTP GET request: + +```bash +export TOKEN= +export AUTHY_ID= +curl -i "https://api.authy.com/protected/json/verify/${TOKEN}/${AUTHY_ID}" \ + -H "X-Authy-API-Key: $AUTHY_API_KEY" +``` + +So inside your hook you need to check: + +- the HTTP response code for the verify request, it must be `200` +- the JSON reponse body, it must contains the key `success` with the value `true` (as string) + +If these conditions are met the token is valid and you allow the user to login. + +We provide two examples: + +- [Keyboard interactive authentication](./keyint/README.md) for 2FA using password + Authy one time token. +- [External authentication](./extauth/README.md) using Authy one time tokens as passwords. + +Please note that these are sample programs not intended for production use, you should write your own hook based on them and you should prefer HTTP based hooks if performance is a concern. diff --git a/examples/OTP/authy/extauth/README.md b/examples/OTP/authy/extauth/README.md new file mode 100644 index 00000000..d6f6683e --- /dev/null +++ b/examples/OTP/authy/extauth/README.md @@ -0,0 +1,3 @@ +# Authy external authentication + +This example shows how to use Authy TOTP token as password for SFTPGo users. Please read the [sample code](./main.go), it should be self explanatory. diff --git a/examples/OTP/authy/extauth/go.mod b/examples/OTP/authy/extauth/go.mod new file mode 100644 index 00000000..dd586f95 --- /dev/null +++ b/examples/OTP/authy/extauth/go.mod @@ -0,0 +1,3 @@ +module github.com/drakkan/sftpgo/authy/extauth + +go 1.15 diff --git a/examples/OTP/authy/extauth/main.go b/examples/OTP/authy/extauth/main.go new file mode 100644 index 00000000..f81f9a22 --- /dev/null +++ b/examples/OTP/authy/extauth/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "time" +) + +type userMapping struct { + SFTPGoUsername string + AuthyID int64 + AuthyAPIKey string +} + +// we assume that the SFTPGo already exists, we only check the one time token. +// If you need to create the SFTPGo user more fields are needed here +type minimalSFTPGoUser struct { + Status int `json:"status,omitempty"` + Username string `json:"username"` + HomeDir string `json:"home_dir,omitempty"` + Permissions map[string][]string `json:"permissions"` +} + +var ( + mapping []userMapping +) + +func init() { + // this is for demo only, you probably want to get this mapping dynamically, for example using a database query + mapping = append(mapping, userMapping{ + SFTPGoUsername: "", + AuthyID: 1234567, + AuthyAPIKey: "", + }) +} + +func printResponse(username string) { + u := minimalSFTPGoUser{ + Username: username, + Status: 1, + HomeDir: filepath.Join(os.TempDir(), username), + } + u.Permissions = make(map[string][]string) + u.Permissions["/"] = []string{"*"} + resp, _ := json.Marshal(u) + fmt.Printf("%v\n", string(resp)) + if len(username) > 0 { + os.Exit(0) + } else { + os.Exit(1) + } +} + +func main() { + // get credentials from env vars + username := os.Getenv("SFTPGO_AUTHD_USERNAME") + password := os.Getenv("SFTPGO_AUTHD_PASSWORD") + if len(password) == 0 { + // login method is not password + printResponse("") + return + } + + for _, m := range mapping { + if m.SFTPGoUsername == username { + // mapping found we can now verify the token + url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", password, m.AuthyID) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + log.Fatal(err) + } + req.Header.Set("X-Authy-API-Key", m.AuthyAPIKey) + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := httpClient.Do(req) + if err != nil { + printResponse("") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // status code 200 is expected + printResponse("") + } + var authyResponse map[string]interface{} + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + printResponse("") + } + err = json.Unmarshal(respBody, &authyResponse) + if err != nil { + printResponse("") + } + if authyResponse["success"].(string) == "true" { + printResponse(username) + } + printResponse("") + break + } + } + + // no mapping found + printResponse("") +} diff --git a/examples/OTP/authy/keyint/README.md b/examples/OTP/authy/keyint/README.md new file mode 100644 index 00000000..604cc28b --- /dev/null +++ b/examples/OTP/authy/keyint/README.md @@ -0,0 +1,3 @@ +# Authy keyboard interactive authentication + +This example shows how to authenticate SFTP users using 2FA (password + Authy token). Please read the [sample code](./main.go), it should be self explanatory. diff --git a/examples/OTP/authy/keyint/go.mod b/examples/OTP/authy/keyint/go.mod new file mode 100644 index 00000000..10cf0862 --- /dev/null +++ b/examples/OTP/authy/keyint/go.mod @@ -0,0 +1,3 @@ +module github.com/drakkan/sftpgo/authy/keyint + +go 1.15 diff --git a/examples/OTP/authy/keyint/main.go b/examples/OTP/authy/keyint/main.go new file mode 100644 index 00000000..3ad0a1bb --- /dev/null +++ b/examples/OTP/authy/keyint/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "time" +) + +type userMapping struct { + SFTPGoUsername string + AuthyID int64 + AuthyAPIKey string +} + +type keyboardAuthHookResponse struct { + Instruction string `json:"instruction,omitempty"` + Questions []string `json:"questions,omitempty"` + Echos []bool `json:"echos,omitempty"` + AuthResult int `json:"auth_result"` + CheckPwd int `json:"check_password,omitempty"` +} + +var ( + mapping []userMapping +) + +func init() { + // this is for demo only, you probably want to get this mapping dynamically, for example using a database query + mapping = append(mapping, userMapping{ + SFTPGoUsername: "", + AuthyID: 1234567, + AuthyAPIKey: "", + }) +} + +func printAuthResponse(result int) { + resp, _ := json.Marshal(keyboardAuthHookResponse{ + AuthResult: result, + }) + fmt.Printf("%v\n", string(resp)) + if result == 1 { + os.Exit(0) + } else { + os.Exit(1) + } +} + +func main() { + // get credentials from env vars + username := os.Getenv("SFTPGO_AUTHD_USERNAME") + var userMap userMapping + for _, m := range mapping { + if m.SFTPGoUsername == username { + userMap = m + break + } + } + + if userMap.SFTPGoUsername != username { + // no mapping found + os.Exit(1) + } + + checkPwdQuestion := keyboardAuthHookResponse{ + Instruction: "This is a sample keyboard authentication program that ask for your password + Authy token", + Questions: []string{"Your password: "}, + Echos: []bool{false}, + CheckPwd: 1, + AuthResult: 0, + } + + q, _ := json.Marshal(checkPwdQuestion) + fmt.Printf("%v\n", string(q)) + + // in a real world app you probably want to use a read timeout + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + if scanner.Err() != nil { + printAuthResponse(-1) + } + response := scanner.Text() + if response != "OK" { + printAuthResponse(-1) + } + + checkTokenQuestion := keyboardAuthHookResponse{ + Instruction: "", + Questions: []string{"Authy token: "}, + Echos: []bool{false}, + CheckPwd: 0, + AuthResult: 0, + } + + q, _ = json.Marshal(checkTokenQuestion) + fmt.Printf("%v\n", string(q)) + scanner.Scan() + if scanner.Err() != nil { + printAuthResponse(-1) + } + authyToken := scanner.Text() + + url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", authyToken, userMap.AuthyID) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + printAuthResponse(-1) + } + req.Header.Set("X-Authy-API-Key", userMap.AuthyAPIKey) + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := httpClient.Do(req) + if err != nil { + printAuthResponse(-1) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // status code 200 is expected + printAuthResponse(-1) + } + var authyResponse map[string]interface{} + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + printAuthResponse(-1) + } + err = json.Unmarshal(respBody, &authyResponse) + if err != nil { + printAuthResponse(-1) + } + if authyResponse["success"].(string) == "true" { + printAuthResponse(1) + } + printAuthResponse(-1) +} diff --git a/examples/ldapauth/main.go b/examples/ldapauth/main.go index df93e93a..8bb9a259 100644 --- a/examples/ldapauth/main.go +++ b/examples/ldapauth/main.go @@ -35,8 +35,8 @@ func exitError() { u := minimalSFTPGoUser{ Username: "", } - json, _ := json.Marshal(u) - fmt.Printf("%v\n", string(json)) + resp, _ := json.Marshal(u) + fmt.Printf("%v\n", string(resp)) os.Exit(1) } @@ -52,8 +52,8 @@ func printSuccessResponse(username, homeDir string, uid, gid int) { u.Permissions["/"] = []string{"*"} // uncomment the next line to require publickey+password authentication //u.Filters.DeniedLoginMethods = []string{"publickey", "password", "keyboard-interactive", "publickey+keyboard-interactive"} - json, _ := json.Marshal(u) - fmt.Printf("%v\n", string(json)) + resp, _ := json.Marshal(u) + fmt.Printf("%v\n", string(resp)) os.Exit(0) } diff --git a/init/sftpgo.service b/init/sftpgo.service index 118720e5..abc3527e 100644 --- a/init/sftpgo.service +++ b/init/sftpgo.service @@ -1,5 +1,5 @@ [Unit] -Description=SFTPGo SFTP Server +Description=SFTPGo Server After=network.target [Service]