Merge pull request #464 from bgirardeau/master

Add Multi-factor authentication with Duo
This commit is contained in:
Armon Dadgar 2015-07-30 17:51:31 -07:00
commit b2d37df7f4
21 changed files with 2368 additions and 16 deletions

4
Godeps/Godeps.json generated
View File

@ -58,6 +58,10 @@
"Comment": "v2.0.0-18-gc904d70",
"Rev": "c904d7032a70da6551c43929f199244f6a45f4c1"
},
{
"ImportPath": "github.com/duosecurity/duo_api_golang",
"Rev": "16da9e74793f6d9b97b227a0696fe32bcdaecb42"
},
{
"ImportPath": "github.com/fatih/structs",
"Rev": "a9f7daa9c2729e97450c2da2feda19130a367d8f"

View File

@ -0,0 +1,25 @@
Copyright (c) 2015, Duo Security, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. The name of the author may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1,14 @@
# Overview
**duo_client** - Demonstration client to call Duo API methods
with Go.
# Duo Auth API
The Duo Auth API provides a low-level API for adding strong two-factor
authentication to applications that cannot directly display rich web
content.
For more information see the Duo Auth API guide:
<http://www.duosecurity.com/docs/authapi>

View File

@ -0,0 +1,380 @@
package authapi
import(
"github.com/duosecurity/duo_api_golang"
"encoding/json"
"net/url"
"strconv"
)
type AuthApi struct {
api duoapi.DuoApi
}
// Build a new Duo Auth API object.
// api is a duoapi.DuoApi object used to make the Duo Rest API calls.
// Example: authapi.NewAuthApi(*duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second)))
func NewAuthApi(api duoapi.DuoApi) *AuthApi {
return &AuthApi{api: api}
}
// API calls will return a StatResult object. On success, Stat is 'OK'.
// On error, Stat is 'FAIL', and Code, Message, and Message_Detail
// contain error information.
type StatResult struct {
Stat string
Code *int32
Message *string
Message_Detail *string
}
// Return object for the 'Ping' API call.
type PingResult struct {
StatResult
Response struct {
Time int64
}
}
// Duo's Ping method. https://www.duosecurity.com/docs/authapi#/ping
// This is an unsigned Duo Rest API call which returns the Duo system's time.
// Use this method to determine whether your system time is in sync with Duo's.
func (api *AuthApi) Ping() (*PingResult, error) {
_, body, err := api.api.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout)
if err != nil {
return nil, err
}
ret := &PingResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
// Return object for the 'Check' API call.
type CheckResult struct {
StatResult
Response struct {
Time int64
}
}
// Call Duo's Check method. https://www.duosecurity.com/docs/authapi#/check
// Check is a signed Duo API call, which returns the Duo system's time.
// Use this method to determine whether your ikey, skey and host are correct,
// and whether your system time is in sync with Duo's.
func (api *AuthApi) Check() (*CheckResult, error) {
_, body, err := api.api.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout)
if err != nil {
return nil, err
}
ret := &CheckResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
// Return object for the 'Logo' API call.
type LogoResult struct {
StatResult
png *[]byte
}
// Duo's Logo method. https://www.duosecurity.com/docs/authapi#/logo
// If the API call is successful, the configured logo png is returned. Othwerwise,
// error information is returned in the LogoResult return value.
func (api *AuthApi) Logo() (*LogoResult, error) {
resp, body, err := api.api.SignedCall("GET", "/auth/v2/logo", nil, duoapi.UseTimeout)
if err != nil {
return nil, err
}
if resp.StatusCode == 200 {
ret := &LogoResult{StatResult:StatResult{Stat: "OK"},
png: &body}
return ret, nil
}
ret := &LogoResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
// Optional parameter for the Enroll method.
func EnrollUsername(username string) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("username", username)
}
}
// Optional parameter for the Enroll method.
func EnrollValidSeconds(secs uint64) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("valid_secs", strconv.FormatUint(secs, 10))
}
}
// Enroll return type.
type EnrollResult struct {
StatResult
Response struct {
Activation_Barcode string
Activation_Code string
Expiration int64
User_Id string
Username string
}
}
// Duo's Enroll method. https://www.duosecurity.com/docs/authapi#/enroll
// Use EnrollUsername() to include the optional username parameter.
// Use EnrollValidSeconds() to change the default validation time limit that the
// user has to complete enrollment.
func (api *AuthApi) Enroll(options ...func(*url.Values)) (*EnrollResult, error) {
opts := url.Values{}
for _, o := range options {
o(&opts)
}
_, body, err := api.api.SignedCall("POST", "/auth/v2/enroll", opts, duoapi.UseTimeout)
if err != nil {
return nil, err
}
ret := &EnrollResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
// Response is "success", "invalid" or "waiting".
type EnrollStatusResult struct {
StatResult
Response string
}
// Duo's EnrollStatus method. https://www.duosecurity.com/docs/authapi#/enroll_status
// Return the status of an outstanding Enrollment.
func (api *AuthApi) EnrollStatus(userid string,
activationCode string) (*EnrollStatusResult, error) {
queryArgs := url.Values{}
queryArgs.Set("user_id", userid)
queryArgs.Set("activation_code", activationCode)
_, body, err := api.api.SignedCall("POST",
"/auth/v2/enroll_status",
queryArgs,
duoapi.UseTimeout)
if err != nil {
return nil, err
}
ret := &EnrollStatusResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
// Preauth return type.
type PreauthResult struct {
StatResult
Response struct {
Result string
Status_Msg string
Enroll_Portal_Url string
Devices []struct {
Device string
Type string
Name string
Number string
Capabilities []string
}
}
}
func PreauthUserId(userid string) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("user_id", userid)
}
}
func PreauthUsername(username string) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("username", username)
}
}
func PreauthIpAddr(ip string) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("ipaddr", ip)
}
}
func PreauthTrustedToken(trustedtoken string) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("trusted_device_token", trustedtoken)
}
}
// Duo's Preauth method. https://www.duosecurity.com/docs/authapi#/preauth
// options Optional values to include in the preauth call.
// Use PreauthUserId to specify the user_id parameter.
// Use PreauthUsername to specify the username parameter. You must
// specify PreauthUserId or PreauthUsername, but not both.
// Use PreauthIpAddr to include the ipaddr parameter, the ip address
// of the client attempting authroization.
// Use PreauthTrustedToken to specify the trusted_device_token parameter.
func (api *AuthApi) Preauth(options ...func(*url.Values)) (*PreauthResult, error) {
opts := url.Values{}
for _, o := range options {
o(&opts)
}
_, body, err := api.api.SignedCall("POST", "/auth/v2/preauth", opts, duoapi.UseTimeout)
if err != nil {
return nil, err
}
ret := &PreauthResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
func AuthUserId(userid string) func(*url.Values) {
return func(opts *url.Values) {
opts.Set("user_id", userid)
}
}
func AuthUsername(username string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("username", username)
}
}
func AuthIpAddr(ip string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("ipaddr", ip)
}
}
func AuthAsync() func (*url.Values) {
return func(opts *url.Values) {
opts.Set("async", "1")
}
}
func AuthDevice(device string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("device", device)
}
}
func AuthType(type_ string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("type", type_)
}
}
func AuthDisplayUsername(username string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("display_username", username)
}
}
func AuthPushinfo(pushinfo string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("pushinfo", pushinfo)
}
}
func AuthPasscode(passcode string) func (*url.Values) {
return func(opts *url.Values) {
opts.Set("passcode", passcode)
}
}
// Auth return type.
type AuthResult struct {
StatResult
Response struct {
// Synchronous
Result string
Status string
Status_Msg string
// Asynchronous
Txid string
}
}
// Duo's Auth method. https://www.duosecurity.com/docs/authapi#/auth
// Factor must be one of 'auto', 'push', 'passcode', 'sms' or 'phone'.
// Use AuthUserId to specify the user_id.
// Use AuthUsername to speicy the username. You must specify either AuthUserId
// or AuthUsername, but not both.
// Use AuthIpAddr to include the client's IP address.
// Use AuthAsync to toggle whether the call blocks for the user's response or not.
// If used asynchronously, get the auth status with the AuthStatus method.
// When using factor 'push', use AuthDevice to specify the device ID to push to.
// When using factor 'push', use AuthType to display some extra auth text to the user.
// When using factor 'push', use AuthDisplayUsername to display some extra text
// to the user.
// When using factor 'push', use AuthPushInfo to include some URL-encoded key/value
// pairs to display to the user.
// When using factor 'passcode', use AuthPasscode to specify the passcode entered
// by the user.
// When using factor 'sms' or 'phone', use AuthDevice to specify which device
// should receive the SMS or phone call.
func (api *AuthApi) Auth(factor string, options ...func(*url.Values)) (*AuthResult, error) {
params := url.Values{}
for _, o := range options {
o(&params)
}
params.Set("factor", factor)
var apiOps []duoapi.DuoApiOption
if _, ok := params["async"]; ok == true {
apiOps = append(apiOps, duoapi.UseTimeout)
}
_, body, err := api.api.SignedCall("POST", "/auth/v2/auth", params, apiOps...)
if err != nil {
return nil, err
}
ret := &AuthResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}
// AuthStatus return type.
type AuthStatusResult struct {
StatResult
Response struct {
Result string
Status string
Status_Msg string
Trusted_Device_Token string
}
}
// Duo's auth_status method. https://www.duosecurity.com/docs/authapi#/auth_status
// When using the Auth call in async mode, use this method to retrieve the
// result of the authentication attempt.
// txid is returned by the Auth call.
func (api *AuthApi) AuthStatus(txid string) (*AuthStatusResult, error) {
opts := url.Values{}
opts.Set("txid", txid)
_, body, err := api.api.SignedCall("GET", "/auth/v2/auth_status", opts)
if err != nil {
return nil, err
}
ret := &AuthStatusResult{}
if err = json.Unmarshal(body, ret); err != nil {
return nil, err
}
return ret, nil
}

View File

@ -0,0 +1,497 @@
package authapi
import (
"testing"
"fmt"
"strings"
"net/http"
"net/http/httptest"
"time"
"github.com/duosecurity/duo_api_golang"
)
func buildAuthApi(url string) *AuthApi {
ikey := "eyekey"
skey := "esskey"
host := strings.Split(url, "//")[1]
userAgent := "GoTestClient"
return NewAuthApi(*duoapi.NewDuoApi(ikey,
skey,
host,
userAgent,
duoapi.SetTimeout(1*time.Second),
duoapi.SetInsecure()))
}
// Timeouts are set to 1 second. Take 15 seconds to respond and verify
// that the client times out.
func TestTimeout(t *testing.T) {
ts := httptest.NewTLSServer(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
time.Sleep(15*time.Second)
}))
duo := buildAuthApi(ts.URL)
start := time.Now()
_, err := duo.Ping()
duration := time.Since(start)
if duration.Seconds() > 2 {
t.Error("Timeout took %d seconds", duration.Seconds())
}
if err == nil {
t.Error("Expected timeout error.")
}
}
// Test a successful ping request / response.
func TestPing(t *testing.T) {
ts := httptest.NewTLSServer(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"time": 1357020061,
"unexpected_parameter" : "blah"
}
}`)
}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
result, err := duo.Ping()
if err != nil {
t.Error("Unexpected error from Ping call" + err.Error())
}
if result.Stat != "OK" {
t.Error("Expected OK, but got " + result.Stat)
}
if result.Response.Time != 1357020061 {
t.Errorf("Expected 1357020061, but got %d", result.Response.Time)
}
}
// Test a successful Check request / response.
func TestCheck(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"time": 1357020061
}
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
result, err := duo.Check()
if err != nil {
t.Error("Failed TestCheck: " + err.Error())
}
if result.Stat != "OK" {
t.Error("Expected OK, but got " + result.Stat)
}
if result.Response.Time != 1357020061 {
t.Errorf("Expected 1357020061, but got %d", result.Response.Time)
}
}
// Test a successful logo request / response.
func TestLogo(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00" +
"\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00" +
"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" +
"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00" +
"\x00\x00\x00IEND\xaeB`\x82"))
}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
_, err := duo.Logo()
if err != nil {
t.Error("Failed TestCheck: " + err.Error())
}
}
// Test a failure logo reqeust / response.
func TestLogoError(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
// Return a 400, as if the logo was not found.
w.WriteHeader(400)
fmt.Fprintln(w, `
{
"stat": "FAIL",
"code": 40002,
"message": "Logo not found",
"message_detail": "Why u no have logo?"
}`)
}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
res, err := duo.Logo()
if err != nil {
t.Error("Failed TestCheck: " + err.Error())
}
if res.Stat != "FAIL" {
t.Error("Expected FAIL, but got " + res.Stat)
}
if res.Code == nil || *res.Code != 40002 {
t.Error("Unexpected response code.")
}
if res.Message == nil || *res.Message != "Logo not found" {
t.Error("Unexpected message.")
}
if res.Message_Detail == nil || *res.Message_Detail != "Why u no have logo?" {
t.Error("Unexpected message detail.")
}
}
// Test a successful enroll request / response.
func TestEnroll(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
if r.FormValue("username") != "49c6c3097adb386048c84354d82ea63d" {
t.Error("TestEnroll failed to set 'username' query parameter:" +
r.RequestURI)
}
if r.FormValue("valid_secs") != "10" {
t.Error("TestEnroll failed to set 'valid_secs' query parameter: " +
r.RequestURI)
}
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"activation_barcode": "https://api-eval.duosecurity.com/frame/qr?value=8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA",
"activation_code": "duo://8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA",
"expiration": 1357020061,
"user_id": "DU94SWSN4ADHHJHF2HXT",
"username": "49c6c3097adb386048c84354d82ea63d"
}
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
result, err := duo.Enroll(EnrollUsername("49c6c3097adb386048c84354d82ea63d"), EnrollValidSeconds(10))
if err != nil {
t.Error("Failed TestEnroll: " + err.Error())
}
if result.Stat != "OK" {
t.Error("Expected OK, but got " + result.Stat)
}
if result.Response.Activation_Barcode != "https://api-eval.duosecurity.com/frame/qr?value=8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA" {
t.Error("Unexpected activation_barcode: " + result.Response.Activation_Barcode)
}
if result.Response.Activation_Code != "duo://8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA" {
t.Error("Unexpected activation code: " + result.Response.Activation_Code)
}
if result.Response.Expiration != 1357020061 {
t.Errorf("Unexpected expiration time: %d", result.Response.Expiration)
}
if result.Response.User_Id != "DU94SWSN4ADHHJHF2HXT" {
t.Error("Unexpected user id: " + result.Response.User_Id)
}
if result.Response.Username != "49c6c3097adb386048c84354d82ea63d" {
t.Error("Unexpected username: " + result.Response.Username)
}
}
// Test a succesful enroll status request / response.
func TestEnrollStatus(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
if r.FormValue("user_id") != "49c6c3097adb386048c84354d82ea63d" {
t.Error("TestEnrollStatus failed to set 'user_id' query parameter:" +
r.RequestURI)
}
if r.FormValue("activation_code") != "10" {
t.Error("TestEnrollStatus failed to set 'activation_code' query parameter: " +
r.RequestURI)
}
fmt.Fprintln(w, `
{
"stat": "OK",
"response": "success"
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
result, err := duo.EnrollStatus("49c6c3097adb386048c84354d82ea63d", "10")
if err != nil {
t.Error("Failed TestEnrollStatus: " + err.Error())
}
if result.Stat != "OK" {
t.Error("Expected OK, but got " + result.Stat)
}
if result.Response != "success" {
t.Error("Unexpected response: " + result.Response)
}
}
// Test a successful preauth with user id. The client doesn't enforce api requirements,
// such as requiring only one of user id or username, but we'll cover the username
// in another test anyway.
func TestPreauthUserId(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
if r.FormValue("ipaddr") != "127.0.0.1" {
t.Error("TestPreauth failed to set 'ipaddr' query parameter:" +
r.RequestURI)
}
if r.FormValue("user_id") != "10" {
t.Error("TestEnrollStatus failed to set 'user_id' query parameter: " +
r.RequestURI)
}
if r.FormValue("trusted_device_token") != "l33t" {
t.Error("TestEnrollStatus failed to set 'trusted_device_token' query parameter: " +
r.RequestURI)
}
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"result": "auth",
"status_msg": "Account is active",
"devices": [
{
"device": "DPFZRS9FB0D46QFTM891",
"type": "phone",
"number": "XXX-XXX-0100",
"name": "",
"capabilities": [
"push",
"sms",
"phone"
]
},
{
"device": "DHEKH0JJIYC1LX3AZWO4",
"type": "token",
"name": "0"
}
]
}
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
res, err := duo.Preauth(PreauthUserId("10"), PreauthIpAddr("127.0.0.1"), PreauthTrustedToken("l33t"))
if err != nil {
t.Error("Failed TestPreauthUserId: " + err.Error())
}
if res.Stat != "OK" {
t.Error("Unexpected stat: " + res.Stat)
}
if res.Response.Result != "auth" {
t.Error("Unexpected response result: " + res.Response.Result)
}
if res.Response.Status_Msg != "Account is active" {
t.Error("Unexpected status message: " + res.Response.Status_Msg)
}
if len(res.Response.Devices) != 2 {
t.Errorf("Unexpected devices length: %d", len(res.Response.Devices))
}
if res.Response.Devices[0].Device != "DPFZRS9FB0D46QFTM891" {
t.Error("Unexpected [0] device name: " + res.Response.Devices[0].Device)
}
if res.Response.Devices[0].Type != "phone" {
t.Error("Unexpected [0] device type: " + res.Response.Devices[0].Type)
}
if res.Response.Devices[0].Number != "XXX-XXX-0100" {
t.Error("Unexpected [0] device number: " + res.Response.Devices[0].Number)
}
if res.Response.Devices[0].Name != "" {
t.Error("Unexpected [0] devices name :" + res.Response.Devices[0].Name)
}
if len(res.Response.Devices[0].Capabilities) != 3 {
t.Errorf("Unexpected [0] device capabilities length: %d", len(res.Response.Devices[0].Capabilities))
}
if res.Response.Devices[0].Capabilities[0] != "push" {
t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[0])
}
if res.Response.Devices[0].Capabilities[1] != "sms" {
t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[1])
}
if res.Response.Devices[0].Capabilities[2] != "phone" {
t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[2])
}
if res.Response.Devices[1].Device != "DHEKH0JJIYC1LX3AZWO4" {
t.Error("Unexpected [1] device name: " + res.Response.Devices[1].Device)
}
if res.Response.Devices[1].Type != "token" {
t.Error("Unexpected [1] device type: " + res.Response.Devices[1].Type)
}
if res.Response.Devices[1].Name != "0" {
t.Error("Unexpected [1] devices name :" + res.Response.Devices[1].Name)
}
if len(res.Response.Devices[1].Capabilities) != 0 {
t.Errorf("Unexpected [1] device capabilities length: %d", len(res.Response.Devices[1].Capabilities))
}
}
// Test preauth enroll with username, and an enroll response.
func TestPreauthEnroll(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
if r.FormValue("username") != "10" {
t.Error("TestEnrollStatus failed to set 'username' query parameter: " +
r.RequestURI)
}
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"enroll_portal_url": "https://api-3945ef22.duosecurity.com/portal?48bac5d9393fb2c2",
"result": "enroll",
"status_msg": "Enroll an authentication device to proceed"
}
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
res, err := duo.Preauth(PreauthUsername("10"))
if err != nil {
t.Error("Failed TestPreauthEnroll: " + err.Error())
}
if res.Stat != "OK" {
t.Error("Unexpected stat: " + res.Stat)
}
if res.Response.Enroll_Portal_Url != "https://api-3945ef22.duosecurity.com/portal?48bac5d9393fb2c2" {
t.Error("Unexpected enroll portal URL: " + res.Response.Enroll_Portal_Url)
}
if res.Response.Result != "enroll" {
t.Error("Unexpected response result: " + res.Response.Result)
}
if res.Response.Status_Msg != "Enroll an authentication device to proceed" {
t.Error("Unexpected status msg: " + res.Response.Status_Msg)
}
}
// Test an authentication request / response. This won't work against the Duo
// server, because the request parameters included are illegal. But we can
// verify that the go code sets the query parameters correctly.
func TestAuth(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
expected := map[string]string {
"username" : "username value",
"user_id" : "user_id value",
"factor" : "auto",
"ipaddr" : "40.40.40.10",
"async" : "1",
"device" : "primary",
"type" : "request",
"display_username" : "display username",
}
for key, value := range expected {
if r.FormValue(key) != value {
t.Errorf("TestAuth failed to set '%s' query parameter: " +
r.RequestURI, key)
}
}
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"result": "allow",
"status": "allow",
"status_msg": "Success. Logging you in..."
}
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
res, err := duo.Auth("auto",
AuthUserId("user_id value"),
AuthUsername("username value"),
AuthIpAddr("40.40.40.10"),
AuthAsync(),
AuthDevice("primary"),
AuthType("request"),
AuthDisplayUsername("display username"),
)
if err != nil {
t.Error("Failed TestAuth: " + err.Error())
}
if res.Stat != "OK" {
t.Error("Unexpected stat: " + res.Stat)
}
if res.Response.Result != "allow" {
t.Error("Unexpected response result: " + res.Response.Result)
}
if res.Response.Status != "allow" {
t.Error("Unexpected response status: " + res.Response.Status)
}
if res.Response.Status_Msg != "Success. Logging you in..." {
t.Error("Unexpected response status msg: " + res.Response.Status_Msg)
}
}
// Test AuthStatus request / response.
func TestAuthStatus(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(
func (w http.ResponseWriter, r *http.Request) {
expected := map[string]string {
"txid" : "4",
}
for key, value := range expected {
if r.FormValue(key) != value {
t.Errorf("TestAuthStatus failed to set '%s' query parameter: " +
r.RequestURI, key)
}
}
fmt.Fprintln(w, `
{
"stat": "OK",
"response": {
"result": "waiting",
"status": "pushed",
"status_msg": "Pushed a login request to your phone..."
}
}`)}))
defer ts.Close()
duo := buildAuthApi(ts.URL)
res, err := duo.AuthStatus("4")
if err != nil {
t.Error("Failed TestAuthStatus: " + err.Error())
}
if res.Stat != "OK" {
t.Error("Unexpected stat: " + res.Stat)
}
if res.Response.Result != "waiting" {
t.Error("Unexpected response result: " + res.Response.Result)
}
if res.Response.Status != "pushed" {
t.Error("Unexpected response status: " + res.Response.Status)
}
if res.Response.Status_Msg != "Pushed a login request to your phone..." {
t.Error("Unexpected response status msg: " + res.Response.Status_Msg)
}
}

View File

@ -0,0 +1,156 @@
package duoapi
import (
"testing"
"net/url"
"strings"
)
func TestCanonicalize(t *testing.T) {
values := url.Values{}
values.Set("username", "H ell?o")
values.Set("password", "H-._~i")
values.Add("password", "A(!'*)")
params_str := canonicalize("post",
"API-XXX.duosecurity.COM",
"/auth/v2/ping",
values,
"5")
params := strings.Split(params_str, "\n")
if len(params) != 5 {
t.Error("Expected 5 parameters, but got " + string(len(params)))
}
if params[1] != string("POST") {
t.Error("Expected POST, but got " + params[1])
}
if params[2] != string("api-xxx.duosecurity.com") {
t.Error("Expected api-xxx.duosecurity.com, but got " + params[2])
}
if params[3] != string("/auth/v2/ping") {
t.Error("Expected /auth/v2/ping, but got " + params[3])
}
if params[4] != string("password=A%28%21%27%2A%29&password=H-._~i&username=H%20ell%3Fo") {
t.Error("Expected sorted escaped params, but got " + params[4])
}
}
func encodeAndValidate(t *testing.T, input url.Values, output string) {
values := url.Values{}
for key, val := range input {
values.Set(key, val[0])
}
params_str := canonicalize("post",
"API-XXX.duosecurity.com",
"/auth/v2/ping",
values,
"5")
params := strings.Split(params_str, "\n")
if params[4] != output {
t.Error("Mismatch\n" + output + "\n" + params[4])
}
}
func TestSimple(t *testing.T) {
values := url.Values{}
values.Set("realname", "First Last")
values.Set("username", "root")
encodeAndValidate(t, values, "realname=First%20Last&username=root")
}
func TestZero(t *testing.T) {
values := url.Values{}
encodeAndValidate(t, values, "")
}
func TestOne(t *testing.T) {
values := url.Values{}
values.Set("realname", "First Last")
encodeAndValidate(t, values, "realname=First%20Last")
}
func TestPrintableAsciiCharaceters(t *testing.T) {
values := url.Values{}
values.Set("digits", "0123456789")
values.Set("letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
values.Set("punctuation", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
values.Set("whitespace", "\t\n\x0b\x0c\r ")
encodeAndValidate(t, values, "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20")
}
func TestSortOrderWithCommonPrefix(t *testing.T) {
values := url.Values{}
values.Set("foo", "1")
values.Set("foo_bar", "2")
encodeAndValidate(t, values, "foo=1&foo_bar=2")
}
func TestUnicodeFuzzValues(t *testing.T) {
values := url.Values{}
values.Set("bar", "⠕ꪣ㟏䮷㛩찅暎腢슽ꇱ")
values.Set("baz", "ෳ蒽噩馅뢤갺篧潩鍊뤜")
values.Set("foo", "퓎훖礸僀訠輕ﴋ耤岳왕")
values.Set("qux", "讗졆-芎茚쳊ꋔ谾뢲馾")
encodeAndValidate(t, values, "bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE")
}
func TestUnicodeFuzzKeysAndValues(t *testing.T) {
values := url.Values{}
values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰",
"ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐")
values.Set("瑉繋쳻姿﹟获귌逌쿑砓",
"趷倢鋓䋯⁽蜰곾嘗ॆ丰")
values.Set("瑰錔逜麮䃘䈁苘豰ᴱꁂ",
"៙ந鍘꫟ꐪ䢾ﮖ濩럿㋳")
values.Set("싅Ⱍ☠㘗隳F蘅⃨갡头",
"䆪붃萋☕㹮攭ꢵ핫U")
encodeAndValidate(t, values, "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU")
}
func TestSign(t *testing.T) {
values := url.Values{}
values.Set("realname", "First Last")
values.Set("username", "root")
res := sign("DIWJ8X6AEYOR5OMC6TQ1",
"Zh5eGmUq9zpfQnyUIu5OL9iWoMMv5ZNmk3zLJ4Ep",
"POST",
"api-XXXXXXXX.duosecurity.com",
"/accounts/v1/account/list",
"Tue, 21 Aug 2012 17:29:18 -0000",
values)
if res != "Basic RElXSjhYNkFFWU9SNU9NQzZUUTE6MmQ5N2Q2MTY2MzE5Nzgx" +
"YjVhM2EwN2FmMzlkMzY2ZjQ5MTIzNGVkYw==" {
t.Error("Signature did not produce output documented at " +
"https://www.duosecurity.com/docs/authapi :(")
}
}
func TestV2Canonicalize(t *testing.T) {
values := url.Values{}
values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰",
"ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐")
values.Set("瑉繋쳻姿﹟获귌逌쿑砓",
"趷倢鋓䋯⁽蜰곾嘗ॆ丰")
values.Set("瑰錔逜麮䃘䈁苘豰ᴱꁂ",
"៙ந鍘꫟ꐪ䢾ﮖ濩럿㋳")
values.Set("싅Ⱍ☠㘗隳F蘅⃨갡头",
"䆪붃萋☕㹮攭ꢵ핫U")
canon := canonicalize(
"PoSt",
"foO.BAr52.cOm",
"/Foo/BaR2/qux",
values,
"Fri, 07 Dec 2012 17:18:00 -0000")
expected := "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU"
if canon != expected {
t.Error("Mismatch!\n" + expected + "\n" + canon)
}
}
func TestNewDuo(t *testing.T) {
duo := NewDuoApi("ABC", "123", "api-XXXXXXX.duosecurity.com", "go-client")
if duo == nil {
t.Fatal("Failed to create a new Duo Api")
}
}

View File

@ -0,0 +1,355 @@
package duoapi
import (
"strings"
"net/url"
"sort"
"crypto/hmac"
"crypto/sha1"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/base64"
"net/http"
"time"
"io/ioutil"
)
var spaceReplacer *strings.Replacer = strings.NewReplacer("+", "%20")
func canonParams(params url.Values) string {
// Values must be in sorted order
for key, val := range params {
sort.Strings(val)
params[key] = val
}
// Encode will place Keys in sorted order
ordered_params := params.Encode()
// Encoder turns spaces into +, but we need %XX escaping
return spaceReplacer.Replace(ordered_params)
}
func canonicalize(method string,
host string,
uri string,
params url.Values,
date string) string {
var canon [5]string
canon[0] = date
canon[1] = strings.ToUpper(method)
canon[2] = strings.ToLower(host)
canon[3] = uri
canon[4] = canonParams(params)
return strings.Join(canon[:], "\n")
}
func sign(ikey string,
skey string,
method string,
host string,
uri string,
date string,
params url.Values) string {
canon := canonicalize(method, host, uri, params, date)
mac := hmac.New(sha1.New, []byte(skey))
mac.Write([]byte(canon))
sig := hex.EncodeToString(mac.Sum(nil))
auth := ikey + ":" + sig
return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
}
type DuoApi struct {
ikey string
skey string
host string
userAgent string
apiClient *http.Client
authClient *http.Client
}
type apiOptions struct {
timeout time.Duration
insecure bool
}
// Optional parameter for NewDuoApi, used to configure timeouts on API calls.
func SetTimeout(timeout time.Duration) func(*apiOptions) {
return func(opts *apiOptions) {
opts.timeout = timeout
return
}
}
// Optional parameter for testing only. Bypasses all TLS certificate validation.
func SetInsecure() func(*apiOptions) {
return func(opts *apiOptions) {
opts.insecure = true
}
}
// Build an return a DuoApi struct.
// ikey is your Duo integration key
// skey is your Duo integration secret key
// host is your Duo host
// userAgent allows you to specify the user agent string used when making
// the web request to Duo.
// options are optional parameters. Use SetTimeout() to specify a timeout value
// for Rest API calls.
//
// Example: duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second))
func NewDuoApi(ikey string,
skey string,
host string,
userAgent string,
options ...func(*apiOptions)) (*DuoApi) {
opts := apiOptions{}
for _, o := range options {
o(&opts)
}
// Certificate pinning
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM([]byte(duoPinnedCert))
tr := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: opts.insecure,
},
}
return &DuoApi{
ikey: ikey,
skey: skey,
host: host,
userAgent: userAgent,
apiClient: &http.Client{
Timeout: opts.timeout,
Transport: tr,
},
authClient: &http.Client{
Transport: tr,
},
}
}
type requestOptions struct {
timeout bool
}
type DuoApiOption func(*requestOptions)
// Pass to Request or SignedRequest to configure a timeout on the request
func UseTimeout(opts *requestOptions) {
opts.timeout = true
}
func (duoapi *DuoApi) buildOptions(options ...DuoApiOption) (*requestOptions) {
opts := &requestOptions{}
for _, o := range options {
o(opts)
}
return opts
}
// Make an unsigned Duo Rest API call. See Duo's online documentation
// for the available REST API's.
// method is POST or GET
// uri is the URI of the Duo Rest call
// params HTTP query parameters to include in the call.
// options Optional parameters. Use UseTimeout to toggle whether the
// Duo Rest API call should timeout or not.
//
// Example: duo.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout)
func (duoapi *DuoApi) Call(method string,
uri string,
params url.Values,
options ...DuoApiOption) (*http.Response, []byte, error) {
opts := duoapi.buildOptions(options...)
client := duoapi.authClient
if opts.timeout {
client = duoapi.apiClient
}
url := url.URL{
Scheme: "https",
Host: duoapi.host,
Path: uri,
RawQuery: params.Encode(),
}
request, err := http.NewRequest(method, url.String(), nil)
if err != nil {
return nil, nil, err
}
resp, err := client.Do(request)
var body []byte
if err == nil {
body, err = ioutil.ReadAll(resp.Body)
resp.Body.Close()
}
return resp, body, err
}
// Make a signed Duo Rest API call. See Duo's online documentation
// for the available REST API's.
// method is POST or GET
// uri is the URI of the Duo Rest call
// params HTTP query parameters to include in the call.
// options Optional parameters. Use UseTimeout to toggle whether the
// Duo Rest API call should timeout or not.
//
// Example: duo.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout)
func (duoapi *DuoApi) SignedCall(method string,
uri string,
params url.Values,
options ...DuoApiOption) (*http.Response, []byte, error) {
opts := duoapi.buildOptions(options...)
now := time.Now().UTC().Format(time.RFC1123Z)
auth_sig := sign(duoapi.ikey, duoapi.skey, method, duoapi.host, uri, now, params)
url := url.URL{
Scheme: "https",
Host: duoapi.host,
Path: uri,
RawQuery: params.Encode(),
}
request, err := http.NewRequest(method, url.String(), nil)
if err != nil {
return nil, nil, err
}
request.Header.Set("Authorization", auth_sig)
request.Header.Set("Date", now)
client := duoapi.authClient
if opts.timeout {
client = duoapi.apiClient
}
resp, err := client.Do(request)
var body []byte
if err == nil {
body, err = ioutil.ReadAll(resp.Body)
resp.Body.Close()
}
return resp, body, err
}
const duoPinnedCert string = "subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Assured ID Root CA\n" +
"-----BEGIN CERTIFICATE-----\n" +
"MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl\n" +
"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" +
"d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv\n" +
"b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG\n" +
"EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl\n" +
"cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi\n" +
"MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c\n" +
"JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP\n" +
"mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+\n" +
"wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4\n" +
"VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/\n" +
"AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB\n" +
"AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" +
"BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun\n" +
"pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC\n" +
"dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf\n" +
"fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm\n" +
"NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx\n" +
"H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe\n" +
"+o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g==\n" +
"-----END CERTIFICATE-----\n" +
"\n" +
"subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Global Root CA\n" +
"-----BEGIN CERTIFICATE-----\n" +
"MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh\n" +
"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" +
"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD\n" +
"QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT\n" +
"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" +
"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG\n" +
"9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB\n" +
"CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97\n" +
"nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt\n" +
"43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P\n" +
"T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4\n" +
"gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO\n" +
"BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR\n" +
"TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw\n" +
"DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr\n" +
"hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg\n" +
"06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF\n" +
"PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls\n" +
"YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk\n" +
"CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=\n" +
"-----END CERTIFICATE-----\n" +
"\n" +
"subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA\n" +
"-----BEGIN CERTIFICATE-----\n" +
"MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs\n" +
"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" +
"d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j\n" +
"ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL\n" +
"MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3\n" +
"LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug\n" +
"RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm\n" +
"+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW\n" +
"PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM\n" +
"xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB\n" +
"Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3\n" +
"hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg\n" +
"EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF\n" +
"MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA\n" +
"FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec\n" +
"nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z\n" +
"eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF\n" +
"hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2\n" +
"Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe\n" +
"vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep\n" +
"+OkuE6N36B9K\n" +
"-----END CERTIFICATE-----\n" +
"\n" +
"subject= /C=US/O=SecureTrust Corporation/CN=SecureTrust CA\n" +
"-----BEGIN CERTIFICATE-----\n" +
"MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI\n" +
"MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x\n" +
"FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz\n" +
"MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv\n" +
"cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN\n" +
"AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz\n" +
"Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO\n" +
"0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao\n" +
"wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj\n" +
"7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS\n" +
"8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT\n" +
"BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB\n" +
"/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg\n" +
"JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC\n" +
"NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3\n" +
"6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/\n" +
"3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm\n" +
"D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS\n" +
"CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR\n" +
"3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE=\n" +
"-----END CERTIFICATE-----\n" +
"\n" +
"subject= /C=US/O=SecureTrust Corporation/CN=Secure Global CA\n" +
"-----BEGIN CERTIFICATE-----\n" +
"MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK\n" +
"MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x\n" +
"GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx\n" +
"MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg\n" +
"Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG\n" +
"SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ\n" +
"iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa\n" +
"/FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ\n" +
"jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI\n" +
"HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7\n" +
"sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w\n" +
"gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF\n" +
"MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw\n" +
"KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG\n" +
"AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L\n" +
"URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO\n" +
"H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm\n" +
"I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY\n" +
"iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc\n" +
"f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW\n" +
"-----END CERTIFICATE-----\n"

View File

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/go-ldap/ldap"
"github.com/hashicorp/vault/helper/mfa"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
@ -18,11 +19,13 @@ func Backend() *framework.Backend {
Help: backendHelp,
PathsSpecial: &logical.Paths{
Root: []string{
Root: append([]string{
"config",
"groups/*",
"users/*",
},
mfa.MFARootPaths()...,
),
Unauthenticated: []string{
"login/*",
@ -30,11 +33,12 @@ func Backend() *framework.Backend {
},
Paths: append([]*framework.Path{
pathLogin(&b),
pathConfig(&b),
pathGroups(&b),
pathUsers(&b),
}),
},
mfa.MFAPaths(b.Backend, pathLogin(&b))...,
),
AuthRenew: b.pathLoginRenew,
}

View File

@ -32,10 +32,21 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (string, error) {
}
}
path := fmt.Sprintf("auth/%s/login/%s", mount, username)
secret, err := c.Logical().Write(path, map[string]interface{}{
data := map[string]interface{}{
"password": password,
})
}
mfa_method, ok := m["method"]
if ok {
data["method"] = mfa_method
}
mfa_passcode, ok := m["passcode"]
if ok {
data["passcode"] = mfa_passcode
}
path := fmt.Sprintf("auth/%s/login/%s", mount, username)
secret, err := c.Logical().Write(path, data)
if err != nil {
return "", err
}
@ -53,6 +64,10 @@ To use it, first configure it through the "config" endpoint, and then
login by specifying username and password. If password is not provided
on the command line, it will be read from stdin.
If multi-factor authentication (MFA) is enabled, a "method" and/or "passcode"
may be provided depending on the MFA backend enabled. To check
which MFA backend is in use, read "auth/[mount]/mfa_config".
Example: vault auth -method=ldap username=john
`

View File

@ -1,6 +1,7 @@
package userpass
import (
"github.com/hashicorp/vault/helper/mfa"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
@ -15,9 +16,11 @@ func Backend() *framework.Backend {
Help: backendHelp,
PathsSpecial: &logical.Paths{
Root: []string{
Root: append([]string{
"users/*",
},
mfa.MFARootPaths()...,
),
Unauthenticated: []string{
"login/*",
@ -25,9 +28,10 @@ func Backend() *framework.Backend {
},
Paths: append([]*framework.Path{
pathLogin(&b),
pathUsers(&b),
}),
},
mfa.MFAPaths(b.Backend, pathLogin(&b))...,
),
AuthRenew: b.pathLoginRenew,
}

View File

@ -3,9 +3,11 @@ package userpass
import (
"fmt"
"strings"
"os"
"github.com/hashicorp/vault/api"
"github.com/mitchellh/mapstructure"
pwd "github.com/hashicorp/vault/helper/password"
)
type CLIHandler struct{}
@ -15,22 +17,41 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (string, error) {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Mount string `mapstructure:"mount"`
Method string `mapstructure:"method"`
Passcode string `mapstructure:"passcode"`
}
if err := mapstructure.WeakDecode(m, &data); err != nil {
return "", err
}
if data.Username == "" || data.Password == "" {
return "", fmt.Errorf("Both 'username' and 'password' must be specified")
if data.Username == "" {
return "", fmt.Errorf("'username' must be specified")
}
if data.Password == "" {
fmt.Printf("Password (will be hidden): ")
password, err := pwd.Read(os.Stdin)
fmt.Println()
if err != nil {
return "", err
}
data.Password = password
}
if data.Mount == "" {
data.Mount = "userpass"
}
path := fmt.Sprintf("auth/%s/login/%s", data.Mount, data.Username)
secret, err := c.Logical().Write(path, map[string]interface{}{
options := map[string]interface{}{
"password": data.Password,
})
}
if data.Method != "" {
options["method"] = data.Method
}
if data.Passcode != "" {
options["passcode"] = data.Passcode
}
path := fmt.Sprintf("auth/%s/login/%s", data.Mount, data.Username)
secret, err := c.Logical().Write(path, options)
if err != nil {
return "", err
}
@ -45,7 +66,12 @@ func (h *CLIHandler) Help() string {
help := `
The "userpass" credential provider allows you to authenticate with
a username and password. To use it, specify the "username" and "password"
parameters.
parameters. If password is not provided on the command line, it will be
read from stdin.
If multi-factor authentication (MFA) is enabled, a "method" and/or "passcode"
may be provided depending on the MFA backend enabled. To check
which MFA backend is in use, read "auth/[mount]/mfa_config".
Example: vault auth -method=userpass \
username=<user> \

143
helper/mfa/duo/duo.go Normal file
View File

@ -0,0 +1,143 @@
// Package duo provides a Duo MFA handler to authenticate users
// with Duo. This handler is registered as the "duo" type in
// mfa_config.
package duo
import (
"fmt"
"net/url"
"github.com/duosecurity/duo_api_golang/authapi"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
// DuoPaths returns path functions to configure Duo.
func DuoPaths() []*framework.Path {
return []*framework.Path{
pathDuoConfig(),
pathDuoAccess(),
}
}
// DuoRootPaths returns the paths that are used to configure Duo.
func DuoRootPaths() []string {
return []string {
"duo/access",
"duo/config",
}
}
// DuoHandler interacts with the Duo Auth API to authenticate a user
// login request. If successful, the original response from the login
// backend is returned.
func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Response) (
*logical.Response, error) {
duoConfig, err := GetDuoConfig(req)
if err != nil || duoConfig == nil {
return logical.ErrorResponse("Could not load Duo configuration"), nil
}
duoAuthClient, err := GetDuoAuthClient(req, duoConfig)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
username, ok := resp.Auth.Metadata["username"]
if !ok {
return logical.ErrorResponse("Could not read username for MFA"), nil
}
var request *duoAuthRequest = &duoAuthRequest{}
request.successResp = resp
request.username = username
request.method = d.Get("method").(string)
request.passcode = d.Get("passcode").(string)
request.ipAddr = req.Connection.RemoteAddr
return duoHandler(duoConfig, duoAuthClient, request)
}
type duoAuthRequest struct {
successResp *logical.Response
username string
method string
passcode string
ipAddr string
}
func duoHandler(duoConfig *DuoConfig, duoAuthClient AuthClient, request *duoAuthRequest) (
*logical.Response, error) {
duoUser := fmt.Sprintf(duoConfig.UsernameFormat, request.username)
preauth, err := duoAuthClient.Preauth(
authapi.PreauthUsername(duoUser),
authapi.PreauthIpAddr(request.ipAddr),
)
if err != nil || preauth == nil {
return logical.ErrorResponse("Could not call Duo preauth"), nil
}
if preauth.StatResult.Stat != "OK" {
errorMsg := "Could not look up Duo user information"
if preauth.StatResult.Message != nil {
errorMsg = errorMsg + ": " + *preauth.StatResult.Message
}
if preauth.StatResult.Message_Detail != nil {
errorMsg = errorMsg + " (" + *preauth.StatResult.Message_Detail + ")"
}
return logical.ErrorResponse(errorMsg), nil
}
switch preauth.Response.Result {
case "allow":
return request.successResp, err
case "deny":
return logical.ErrorResponse(preauth.Response.Status_Msg), nil
case "enroll":
return logical.ErrorResponse(fmt.Sprintf("%s (%s)",
preauth.Response.Status_Msg,
preauth.Response.Enroll_Portal_Url)), nil
case "auth":
break
default:
return logical.ErrorResponse(fmt.Sprintf("Invalid Duo preauth response: %s",
preauth.Response.Result)), nil
}
options := []func(*url.Values){authapi.AuthUsername(duoUser)}
if request.method == "" {
request.method = "auto"
}
if request.passcode != "" {
request.method = "passcode"
options = append(options, authapi.AuthPasscode(request.passcode))
} else {
options = append(options, authapi.AuthDevice("auto"))
}
result, err := duoAuthClient.Auth(request.method, options...)
if err != nil || result == nil {
return logical.ErrorResponse("Could not call Duo auth"), nil
}
if result.StatResult.Stat != "OK" {
errorMsg := "Could not authenticate Duo user"
if result.StatResult.Message != nil {
errorMsg = errorMsg + ": " + *result.StatResult.Message
}
if result.StatResult.Message_Detail != nil {
errorMsg = errorMsg + " (" + *result.StatResult.Message_Detail + ")"
}
return logical.ErrorResponse(errorMsg), nil
}
if result.Response.Result != "allow" {
return logical.ErrorResponse(result.Response.Status_Msg), nil
}
return request.successResp, nil
}

123
helper/mfa/duo/duo_test.go Normal file
View File

@ -0,0 +1,123 @@
package duo
import (
"encoding/json"
"net/url"
"strings"
"testing"
"github.com/duosecurity/duo_api_golang/authapi"
"github.com/hashicorp/vault/logical"
)
type MockClientData struct {
PreauthData *authapi.PreauthResult
PreauthError error
AuthData *authapi.AuthResult
AuthError error
}
type MockAuthClient struct {
MockData *MockClientData
}
func (c *MockAuthClient) Preauth(options ...func(*url.Values)) (*authapi.PreauthResult, error) {
return c.MockData.PreauthData, c.MockData.PreauthError
}
func (c *MockAuthClient) Auth(factor string, options ...func(*url.Values)) (*authapi.AuthResult, error) {
return c.MockData.AuthData, c.MockData.AuthError
}
func MockGetDuoAuthClient(data *MockClientData) func (*logical.Request, *DuoConfig) (AuthClient, error) {
return func (*logical.Request, *DuoConfig) (AuthClient, error) {
return getDuoAuthClient(data), nil
}
}
func getDuoAuthClient(data *MockClientData) AuthClient {
var c MockAuthClient
// set default response to be successful
preauthSuccessJSON := `
{
"Stat": "OK",
"Response": {
"Result": "auth",
"Status_Msg": "Needs authentication",
"Devices": []
}
}`
if data.PreauthData == nil {
data.PreauthData = &authapi.PreauthResult{}
json.Unmarshal([]byte(preauthSuccessJSON), data.PreauthData)
}
authSuccessJSON := `
{
"Stat": "OK",
"Response": {
"Result": "allow"
}
}`
if data.AuthData == nil {
data.AuthData = &authapi.AuthResult{}
json.Unmarshal([]byte(authSuccessJSON), data.AuthData)
}
c.MockData = data
return &c
}
func TestDuoHandlerSuccess(t *testing.T) {
successResp := &logical.Response{
Auth: &logical.Auth{},
}
duoConfig := &DuoConfig{
UsernameFormat: "%s",
}
duoAuthClient := getDuoAuthClient(&MockClientData{})
resp, err := duoHandler(duoConfig, duoAuthClient, &duoAuthRequest {
successResp: successResp,
username: "",
})
if err != nil {
t.Fatalf(err.Error())
}
if resp != successResp {
t.Fatalf("Testing Duo authentication gave incorrect response (expected success, got: %v)", resp)
}
}
func TestDuoHandlerReject(t *testing.T) {
AuthData := &authapi.AuthResult{}
authRejectJSON := `
{
"Stat": "OK",
"Response": {
"Result": "deny",
"Status_Msg": "Invalid auth"
}
}`
json.Unmarshal([]byte(authRejectJSON), AuthData)
successResp := &logical.Response{
Auth: &logical.Auth{},
}
expectedError := AuthData.Response.Status_Msg
duoConfig := &DuoConfig{
UsernameFormat: "%s",
}
duoAuthClient := getDuoAuthClient(&MockClientData{
AuthData: AuthData,
})
resp, err := duoHandler(duoConfig, duoAuthClient, &duoAuthRequest {
successResp: successResp,
username: "user",
})
if err != nil {
t.Fatalf(err.Error())
}
error, ok := resp.Data["error"].(string)
if !ok || !strings.Contains(error, expectedError) {
t.Fatalf("Testing Duo authentication gave incorrect response (expected deny, got: %v)", error)
}
}

View File

@ -0,0 +1,109 @@
package duo
import (
"fmt"
"net/url"
"github.com/duosecurity/duo_api_golang"
"github.com/duosecurity/duo_api_golang/authapi"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
type AuthClient interface {
Preauth(options ...func(*url.Values)) (*authapi.PreauthResult, error)
Auth(factor string, options ...func(*url.Values)) (*authapi.AuthResult, error)
}
func pathDuoAccess() *framework.Path {
return &framework.Path{
Pattern: `duo/access`,
Fields: map[string]*framework.FieldSchema{
"skey": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Duo secret key",
},
"ikey": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Duo integration key",
},
"host": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Duo api host",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: pathDuoAccessWrite,
},
HelpSynopsis: pathDuoAccessHelpSyn,
HelpDescription: pathDuoAccessHelpDesc,
}
}
func GetDuoAuthClient(req *logical.Request, config *DuoConfig) (AuthClient, error) {
entry, err := req.Storage.Get("duo/access")
if err != nil {
return nil, err
}
if entry == nil {
return nil, fmt.Errorf(
"Duo access credentials haven't been configured. Please configure\n" +
"them at the 'duo/access' endpoint")
}
var access DuoAccess
if err := entry.DecodeJSON(&access); err != nil {
return nil, err
}
duoClient := duoapi.NewDuoApi(
access.IKey,
access.SKey,
access.Host,
config.UserAgent,
)
duoAuthClient := authapi.NewAuthApi(*duoClient)
check, err := duoAuthClient.Check()
if err != nil {
return nil, err
}
if check.StatResult.Stat != "OK" {
return nil, fmt.Errorf("Could not connect to Duo: %s (%s)", *check.StatResult.Message, *check.StatResult.Message_Detail)
}
return duoAuthClient, nil
}
func pathDuoAccessWrite(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
entry, err := logical.StorageEntryJSON("duo/access", DuoAccess{
SKey: d.Get("skey").(string),
IKey: d.Get("ikey").(string),
Host: d.Get("host").(string),
})
if err != nil {
return nil, err
}
if err := req.Storage.Put(entry); err != nil {
return nil, err
}
return nil, nil
}
type DuoAccess struct {
SKey string `json:"skey"`
IKey string `json:"ikey"`
Host string `json:"host"`
}
const pathDuoAccessHelpSyn = `
Configure the access keys and host for Duo API connections.
`
const pathDuoAccessHelpDesc = `
To authenticate users with Duo, the backend needs to know what host to connect to
and must authenticate with an integration key and secret key. This endpoint is used
to configure that information.
`

View File

@ -0,0 +1,100 @@
package duo
import (
"errors"
"strings"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathDuoConfig() *framework.Path {
return &framework.Path{
Pattern: `duo/config`,
Fields: map[string]*framework.FieldSchema{
"user_agent": &framework.FieldSchema{
Type: framework.TypeString,
Description: "User agent to connect to Duo (default \"\")",
},
"username_format": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Format string given auth backend username as argument to create Duo username (default '%s')",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: pathDuoConfigWrite,
logical.ReadOperation: pathDuoConfigRead,
},
HelpSynopsis: pathDuoConfigHelpSyn,
HelpDescription: pathDuoConfigHelpDesc,
}
}
func GetDuoConfig(req *logical.Request) (*DuoConfig, error) {
var result DuoConfig
// all config parameters are optional, so path need not exist
entry, err := req.Storage.Get("duo/config")
if err == nil && entry != nil {
if err := entry.DecodeJSON(&result); err != nil {
return nil, err
}
}
if result.UsernameFormat == "" {
result.UsernameFormat = "%s"
}
return &result, nil
}
func pathDuoConfigWrite(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username_format := d.Get("username_format").(string)
if !strings.Contains(username_format, "%s") {
return nil, errors.New("username_format must include username ('%s')")
}
entry, err := logical.StorageEntryJSON("duo/config", DuoConfig{
UsernameFormat: username_format,
})
if err != nil {
return nil, err
}
if err := req.Storage.Put(entry); err != nil {
return nil, err
}
return nil, nil
}
func pathDuoConfigRead(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
config, err := GetDuoConfig(req)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
return &logical.Response{
Data: map[string]interface{}{
"username_format": config.UsernameFormat,
},
}, nil
}
type DuoConfig struct {
UsernameFormat string `json:"username_format"`
UserAgent string `json:"user_agent"`
}
const pathDuoConfigHelpSyn = `
Configure Duo second factor behavior.
`
const pathDuoConfigHelpDesc = `
This endpoint allows you to configure how the original auth backend username maps to
the Duo username by providing a template format string.
`

86
helper/mfa/mfa.go Normal file
View File

@ -0,0 +1,86 @@
// Package mfa provides wrappers to add multi-factor authentication
// to any auth backend.
//
// To add MFA to a backend, replace its login path with the
// paths returned by MFAPaths and add the additional root
// paths returned by MFARootPaths. The backend provides
// the username to the MFA wrapper in Auth.Metadata['username'].
//
// To add an additional MFA type, create a subpackage that
// implements [Type]Paths, [Type]RootPaths, and [Type]Handler
// functions and add them to MFAPaths, MFARootPaths, and
// handlers respectively.
package mfa
import (
"github.com/hashicorp/vault/helper/mfa/duo"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
// MFAPaths returns paths to wrap the original login path and configure MFA.
// When adding MFA to a backend, these paths should be included instead of
// the login path in Backend.Paths.
func MFAPaths(originalBackend *framework.Backend, loginPath *framework.Path) []*framework.Path {
var b backend
b.Backend = originalBackend
return append(duo.DuoPaths(), pathMFAConfig(&b), wrapLoginPath(&b, loginPath))
}
// MFARootPaths returns path strings used to configure MFA. When adding MFA
// to a backend, these paths should be included in
// Backend.PathsSpecial.Root.
func MFARootPaths() []string {
return append(duo.DuoRootPaths(), "mfa_config")
}
// HandlerFunc is the callback called to handle MFA for a login request.
type HandlerFunc func (*logical.Request, *framework.FieldData, *logical.Response) (*logical.Response, error)
// handlers maps each supported MFA type to its handler.
var handlers = map[string]HandlerFunc{
"duo": duo.DuoHandler,
}
type backend struct {
*framework.Backend
}
func wrapLoginPath(b *backend, loginPath *framework.Path) *framework.Path {
loginPath.Fields["passcode"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: "One time passcode (optional)",
}
loginPath.Fields["method"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: "Multi-factor auth method to use (optional)",
}
// wrap write callback to do MFA after auth
loginHandler := loginPath.Callbacks[logical.WriteOperation]
loginPath.Callbacks[logical.WriteOperation] = b.wrapLoginHandler(loginHandler)
return loginPath
}
func (b *backend) wrapLoginHandler(loginHandler framework.OperationFunc) framework.OperationFunc {
return func (req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// login with original login function first
resp, err := loginHandler(req, d);
if err != nil || resp.Auth == nil {
return resp, err
}
// check if multi-factor enabled
mfa_config, err := b.MFAConfig(req)
if err != nil || mfa_config == nil {
return resp, nil
}
// perform multi-factor authentication if type supported
handler, ok := handlers[mfa_config.Type]
if ok {
return handler(req, d, resp)
} else {
return resp, err
}
}
}

127
helper/mfa/mfa_test.go Normal file
View File

@ -0,0 +1,127 @@
package mfa
import (
"testing"
"github.com/hashicorp/vault/logical"
logicaltest "github.com/hashicorp/vault/logical/testing"
"github.com/hashicorp/vault/logical/framework"
)
// MakeTestBackend creates a simple MFA enabled backend.
// Login (before MFA) always succeeds with policy "foo".
// An MFA "test" type is added to mfa.handlers that succeeds
// if MFA method is "accept", otherwise it rejects.
func MakeTestBackend() *framework.Backend {
handlers["test"] = testMFAHandler
b := &framework.Backend{
Help: "",
PathsSpecial: &logical.Paths{
Root: MFARootPaths(),
Unauthenticated: []string{
"login",
},
},
Paths: MFAPaths(nil, testPathLogin()),
}
return b
}
func testPathLogin() *framework.Path {
return &framework.Path{
Pattern: `login`,
Fields: map[string]*framework.FieldSchema{
"username": &framework.FieldSchema{
Type: framework.TypeString,
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: testPathLoginHandler,
},
}
}
func testPathLoginHandler(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := d.Get("username").(string)
return &logical.Response{
Auth: &logical.Auth{
Policies: []string{"foo"},
Metadata: map[string]string{
"username": username,
},
},
}, nil
}
func testMFAHandler(req *logical.Request, d *framework.FieldData, resp *logical.Response) (
*logical.Response, error) {
if d.Get("method").(string) != "accept" {
return logical.ErrorResponse("Deny access"), nil
} else {
return resp, nil
}
}
func TestMFALogin(t *testing.T) {
b := MakeTestBackend()
logicaltest.Test(t, logicaltest.TestCase{
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepEnableMFA(t),
testAccStepLogin(t, "user"),
},
})
}
func TestMFALoginDenied(t *testing.T) {
b := MakeTestBackend()
logicaltest.Test(t, logicaltest.TestCase{
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepEnableMFA(t),
testAccStepLoginDenied(t, "user"),
},
})
}
func testAccStepEnableMFA(t *testing.T) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.WriteOperation,
Path: "mfa_config",
Data: map[string]interface{}{
"type": "test",
},
}
}
func testAccStepLogin(t *testing.T, username string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.WriteOperation,
Path: "login",
Data: map[string]interface{}{
"method": "accept",
"username": username,
},
Unauthenticated: true,
Check: logicaltest.TestCheckAuth([]string{"foo"}),
}
}
func testAccStepLoginDenied(t *testing.T, username string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.WriteOperation,
Path: "login",
Data: map[string]interface{}{
"method": "deny",
"username": username,
},
Unauthenticated: true,
Check: logicaltest.TestCheckError(),
}
}

View File

@ -0,0 +1,88 @@
package mfa
import (
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathMFAConfig(b *backend) *framework.Path {
return &framework.Path{
Pattern: `mfa_config`,
Fields: map[string]*framework.FieldSchema{
"type": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Enables MFA with given backend (available: duo)",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: b.pathMFAConfigWrite,
logical.ReadOperation: b.pathMFAConfigRead,
},
HelpSynopsis: pathMFAConfigHelpSyn,
HelpDescription: pathMFAConfigHelpDesc,
}
}
func (b *backend) MFAConfig(req *logical.Request) (*MFAConfig, error) {
entry, err := req.Storage.Get("mfa_config")
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
var result MFAConfig
if err := entry.DecodeJSON(&result); err != nil {
return nil, err
}
return &result, nil
}
func (b *backend) pathMFAConfigWrite(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
entry, err := logical.StorageEntryJSON("mfa_config", MFAConfig{
Type: d.Get("type").(string),
})
if err != nil {
return nil, err
}
if err := req.Storage.Put(entry); err != nil {
return nil, err
}
return nil, nil
}
func (b *backend) pathMFAConfigRead(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
config, err := b.MFAConfig(req)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
return &logical.Response{
Data: map[string]interface{}{
"type": config.Type,
},
}, nil
}
type MFAConfig struct {
Type string `json:"type"`
}
const pathMFAConfigHelpSyn = `
Configure multi factor backend.
`
const pathMFAConfigHelpDesc = `
This endpoint allows you to turn on multi-factor authentication with a given backend.
Currently only Duo is supported.
`

View File

@ -3,7 +3,7 @@ layout: "docs"
page_title: "Auth Backend: LDAP"
sidebar_current: "docs-auth-ldap"
description: |-
The "kdap" auth backend allows users to authenticate with Vault using LDAP credentials.
The "ldap" auth backend allows users to authenticate with Vault using LDAP credentials.
---
# Auth Backend: LDAP

View File

@ -0,0 +1,92 @@
---
layout: "docs"
page_title: "Multi-Factor Authentication"
sidebar_current: "docs-auth-mfa"
description: |-
Multi-factor authentication is supported for several authentication backends.
---
# Multi-Factor Authentication
Several authentication backends support multi-factor authentication (MFA). Once enabled for
a backend, users are required to provide additional verification, like a one-time passcode,
before being authenticated.
Currently, the "ldap" and "userpass" backends support MFA.
## Authentication
When authenticating, users still provide the same information as before, in addition to
MFA verification. Usually this is a passcode, but in other cases, like a Duo Push
notification, no additional information is needed.
### Via the CLI
```shell
$ vault auth -method=userpass \
username=user \
password=test \
passcode=111111
```
```shell
$ vault auth -method=userpass \
username=user \
password=test \
method=push
```
### Via the API
The endpoint for the login is the same as for the original backend. Additional
MFA information should be sent in the POST body encoded as JSON.
```shell
$ curl $VAULT_ADDR/v1/auth/userpass/login/user \
-d '{ "password": "test", "passcode": "111111" }'
```
The response is the same as for the original backend.
## Configuration
To enable MFA for a supported backend, the MFA type must be set in `mfa_config`. For example:
```shell
$ vault write auth/userpass/mfa_config type=duo
```
This enables the Duo MFA type, which is currently the only MFA type supported. The username
used for MFA is the same as the login username, unless the backend or MFA type provide
options to behave differently (see Duo configuration below).
### Duo
The Duo MFA type is configured through two paths: `duo/config` and `duo/access`.
`duo/access` contains connection information for the Duo Auth API. To configure:
```shell
$ vault write auth/[mount]/duo/access \
host=[host] \
ikey=[integration key] \
skey=[secret key]
```
`duo/config` is an optional path that contains general configuration information
for Duo authentication. To configure:
```shell
$ vault write auth/[mount]/duo/config \
user_agent="" \
username_format="%s"
```
`user_agent` is the user agent to use when connecting to Duo.
`username_format` controls how the username used to login is
transformed before authenticating with Duo. This field is a format string
that is passed the original username as its first argument and outputs
the new username. For example "%s@example.com" would append "@example.com"
to the provided username before connecting to Duo.
More information can be found through the CLI `path-help` command.

View File

@ -162,6 +162,10 @@
<li<%= sidebar_current("docs-auth-ldap") %>>
<a href="/docs/auth/ldap.html">LDAP</a>
</li>
<li<%= sidebar_current("docs-auth-mfa") %>>
<a href="/docs/auth/mfa.html">MFA</a>
</li>
</ul>
</li>