add some examples hooks for one time password logins
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.
This commit is contained in:
parent
bbc8c091e6
commit
04c9a5c008
10 changed files with 322 additions and 5 deletions
|
@ -164,3 +164,5 @@ Content-Length: 18
|
||||||
|
|
||||||
{"auth_result": 1}
|
{"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
examples/OTP/authy/README.md
Normal file
57
examples/OTP/authy/README.md
Normal file
|
@ -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
examples/OTP/authy/extauth/README.md
Normal file
3
examples/OTP/authy/extauth/README.md
Normal file
|
@ -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
examples/OTP/authy/extauth/go.mod
Normal file
3
examples/OTP/authy/extauth/go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/drakkan/sftpgo/authy/extauth
|
||||||
|
|
||||||
|
go 1.15
|
109
examples/OTP/authy/extauth/main.go
Normal file
109
examples/OTP/authy/extauth/main.go
Normal file
|
@ -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
examples/OTP/authy/keyint/README.md
Normal file
3
examples/OTP/authy/keyint/README.md
Normal file
|
@ -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
examples/OTP/authy/keyint/go.mod
Normal file
3
examples/OTP/authy/keyint/go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/drakkan/sftpgo/authy/keyint
|
||||||
|
|
||||||
|
go 1.15
|
137
examples/OTP/authy/keyint/main.go
Normal file
137
examples/OTP/authy/keyint/main.go
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -35,8 +35,8 @@ func exitError() {
|
||||||
u := minimalSFTPGoUser{
|
u := minimalSFTPGoUser{
|
||||||
Username: "",
|
Username: "",
|
||||||
}
|
}
|
||||||
json, _ := json.Marshal(u)
|
resp, _ := json.Marshal(u)
|
||||||
fmt.Printf("%v\n", string(json))
|
fmt.Printf("%v\n", string(resp))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,8 +52,8 @@ func printSuccessResponse(username, homeDir string, uid, gid int) {
|
||||||
u.Permissions["/"] = []string{"*"}
|
u.Permissions["/"] = []string{"*"}
|
||||||
// uncomment the next line to require publickey+password authentication
|
// uncomment the next line to require publickey+password authentication
|
||||||
//u.Filters.DeniedLoginMethods = []string{"publickey", "password", "keyboard-interactive", "publickey+keyboard-interactive"}
|
//u.Filters.DeniedLoginMethods = []string{"publickey", "password", "keyboard-interactive", "publickey+keyboard-interactive"}
|
||||||
json, _ := json.Marshal(u)
|
resp, _ := json.Marshal(u)
|
||||||
fmt.Printf("%v\n", string(json))
|
fmt.Printf("%v\n", string(resp))
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=SFTPGo SFTP Server
|
Description=SFTPGo Server
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|
Loading…
Reference in a new issue