Skip to content

Commit

Permalink
add some examples hooks for one time password logins
Browse files Browse the repository at this point in the history
The examples use Twillo Authy since I use it for my GitHub account.

You can easily use other multi factor authentication software in a
similar way.
  • Loading branch information
drakkan committed Aug 18, 2020
1 parent bbc8c09 commit 04c9a5c
Show file tree
Hide file tree
Showing 10 changed files with 322 additions and 5 deletions.
2 changes: 2 additions & 0 deletions docs/keyboard-interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 57 additions & 0 deletions examples/OTP/authy/README.md
Original file line number Diff line number Diff line change
@@ -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=<your api key here>
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=<TOTP you read from Authy app>
export AUTHY_ID=<user 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.
3 changes: 3 additions & 0 deletions examples/OTP/authy/extauth/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions examples/OTP/authy/extauth/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/drakkan/sftpgo/authy/extauth

go 1.15
109 changes: 109 additions & 0 deletions examples/OTP/authy/extauth/main.go
Original file line number Diff line number Diff line change
@@ -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: "<SFTPGo username>",
AuthyID: 1234567,
AuthyAPIKey: "<your api key>",
})
}

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("")
}
3 changes: 3 additions & 0 deletions examples/OTP/authy/keyint/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions examples/OTP/authy/keyint/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/drakkan/sftpgo/authy/keyint

go 1.15
137 changes: 137 additions & 0 deletions examples/OTP/authy/keyint/main.go
Original file line number Diff line number Diff line change
@@ -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: "<SFTPGo username>",
AuthyID: 1234567,
AuthyAPIKey: "<your api key>",
})
}

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)
}
8 changes: 4 additions & 4 deletions examples/ldapauth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion init/sftpgo.service
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[Unit]
Description=SFTPGo SFTP Server
Description=SFTPGo Server
After=network.target

[Service]
Expand Down

0 comments on commit 04c9a5c

Please sign in to comment.