diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index cc3ff58f67..a12e4e8128 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -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" diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE new file mode 100644 index 0000000000..2510e98ad7 --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE @@ -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. diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md new file mode 100644 index 0000000000..9216f8cd7d --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md @@ -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: + + diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go new file mode 100644 index 0000000000..f8d45036f8 --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go @@ -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(¶ms) + } + 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 +} diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go new file mode 100644 index 0000000000..45fbbaf95f --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go @@ -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) + } +} diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go new file mode 100644 index 0000000000..01278457e3 --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go @@ -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") + } +} diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go new file mode 100644 index 0000000000..1c9d3ac6ef --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go @@ -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" diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go index 8558818676..77e58d38b0 100644 --- a/builtin/credential/ldap/backend.go +++ b/builtin/credential/ldap/backend.go @@ -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, } diff --git a/builtin/credential/ldap/cli.go b/builtin/credential/ldap/cli.go index 38d6c2ef42..79200645c9 100644 --- a/builtin/credential/ldap/cli.go +++ b/builtin/credential/ldap/cli.go @@ -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 ` diff --git a/builtin/credential/userpass/backend.go b/builtin/credential/userpass/backend.go index 0697361ea8..d2e62ab52a 100644 --- a/builtin/credential/userpass/backend.go +++ b/builtin/credential/userpass/backend.go @@ -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, } diff --git a/builtin/credential/userpass/cli.go b/builtin/credential/userpass/cli.go index 08cee9e57f..c5d5cc27af 100644 --- a/builtin/credential/userpass/cli.go +++ b/builtin/credential/userpass/cli.go @@ -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= \ diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go new file mode 100644 index 0000000000..df25316cbf --- /dev/null +++ b/helper/mfa/duo/duo.go @@ -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 +} diff --git a/helper/mfa/duo/duo_test.go b/helper/mfa/duo/duo_test.go new file mode 100644 index 0000000000..e01272d353 --- /dev/null +++ b/helper/mfa/duo/duo_test.go @@ -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) + } +} diff --git a/helper/mfa/duo/path_duo_access.go b/helper/mfa/duo/path_duo_access.go new file mode 100644 index 0000000000..3b43233944 --- /dev/null +++ b/helper/mfa/duo/path_duo_access.go @@ -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. +` diff --git a/helper/mfa/duo/path_duo_config.go b/helper/mfa/duo/path_duo_config.go new file mode 100644 index 0000000000..406072dec1 --- /dev/null +++ b/helper/mfa/duo/path_duo_config.go @@ -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. +` diff --git a/helper/mfa/mfa.go b/helper/mfa/mfa.go new file mode 100644 index 0000000000..2cb28381f9 --- /dev/null +++ b/helper/mfa/mfa.go @@ -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 + } + } +} diff --git a/helper/mfa/mfa_test.go b/helper/mfa/mfa_test.go new file mode 100644 index 0000000000..197ed20b3e --- /dev/null +++ b/helper/mfa/mfa_test.go @@ -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(), + } +} diff --git a/helper/mfa/path_mfa_config.go b/helper/mfa/path_mfa_config.go new file mode 100644 index 0000000000..55a2619480 --- /dev/null +++ b/helper/mfa/path_mfa_config.go @@ -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. +` diff --git a/website/source/docs/auth/ldap.html.md b/website/source/docs/auth/ldap.html.md index f36307c04c..cf4c2fb5be 100644 --- a/website/source/docs/auth/ldap.html.md +++ b/website/source/docs/auth/ldap.html.md @@ -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 diff --git a/website/source/docs/auth/mfa.html.md b/website/source/docs/auth/mfa.html.md new file mode 100644 index 0000000000..a58c78cd43 --- /dev/null +++ b/website/source/docs/auth/mfa.html.md @@ -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. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index a1236cd4f7..d7b95e8018 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -162,6 +162,10 @@ > LDAP + + > + MFA +