From 92b985e05e08564348c7789599de36e414f0b4b5 Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Thu, 24 Aug 2023 09:58:09 -0700 Subject: [PATCH 1/9] bump version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c42635e..0e5fe3b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/duosecurity/duo_api_golang -go 1.15 +go 1.18 From 1dc7bfb043ec80990cd92111f72860f586c525a1 Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Thu, 24 Aug 2023 10:01:50 -0700 Subject: [PATCH 2/9] typo fix --- authapi/authapi.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authapi/authapi.go b/authapi/authapi.go index c91eca6..c782651 100644 --- a/authapi/authapi.go +++ b/authapi/authapi.go @@ -5,7 +5,7 @@ import ( "net/url" "strconv" - "github.com/duosecurity/duo_api_golang" + duoapi "github.com/duosecurity/duo_api_golang" ) type AuthApi struct { @@ -313,7 +313,7 @@ type AuthResult struct { // 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 +// Use AuthUsername to specify 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. From dc8931e0ac63f0383fc8c1960c27dcc5356c9d65 Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Thu, 24 Aug 2023 11:32:55 -0700 Subject: [PATCH 3/9] Add new call for sigv5 --- duoapi.go | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/duoapi.go b/duoapi.go index c4ba5f7..cb321e7 100644 --- a/duoapi.go +++ b/duoapi.go @@ -7,11 +7,14 @@ import ( "crypto/x509" "encoding/base64" "encoding/hex" + "encoding/json" + "errors" "io" "io/ioutil" "math/rand" "net/http" "net/url" + "slices" "sort" "strings" "time" @@ -54,6 +57,42 @@ func canonicalize(method string, return strings.Join(canon[:], "\n") } +func canonicalizeV5(method string, + host string, + uri string, + params url.Values, + body string, + date string) string { + var canon [7]string + canon[0] = date + canon[1] = strings.ToUpper(method) + canon[2] = strings.ToLower(host) + canon[3] = uri + canon[4] = canonParams(params) + canon[5] = hashString(body) + canon[6] = hashString("") // additional headers not needed at this time + return strings.Join(canon[:], "\n") +} + +func hashString(to_hash string) string { + hash := sha512.New() + hash.Write([]byte(to_hash)) + return hex.EncodeToString(hash.Sum(nil)) +} + +func jsonToValues(json JSONParams) (url.Values, error) { + params := url.Values{} + for key, val := range json { + s, ok := val.(string) + if ok { + params[key] = []string{s} + } else { + return nil, errors.New("JSON value not a string") + } + } + return params, nil +} + func sign(ikey string, skey string, method string, @@ -69,6 +108,23 @@ func sign(ikey string, return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) } +func signV5(ikey string, + skey string, + method string, + host string, + uri string, + date string, + params url.Values, + body string, +) string { + canon := canonicalizeV5(method, host, uri, params, body, date) + mac := hmac.New(sha512.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 @@ -133,10 +189,10 @@ func SetTransport(transport func(*http.Transport)) func(*apiOptions) { // 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. Information about the client will be -// appended to the userAgent. +// the web request to Duo. Information about the client will be +// appended to the userAgent. // options are optional parameters. Use SetTimeout() to specify a timeout value -// for Rest API calls. Use SetProxy() to specify proxy settings for Duo API calls. +// for Rest API calls. Use SetProxy() to specify proxy settings for Duo API calls. // // Example: duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second)) func NewDuoApi(ikey string, @@ -227,7 +283,7 @@ func (duoapi *DuoApi) SetCustomHTTPClient(c *http.Client) { // 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. +// Duo Rest API call should timeout or not. // // Example: duo.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout) func (duoapi *DuoApi) Call(method string, @@ -253,7 +309,7 @@ func (duoapi *DuoApi) Call(method string, // 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. +// Duo Rest API call should timeout or not. // // Example: duo.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout) func (duoapi *DuoApi) SignedCall(method string, @@ -282,12 +338,74 @@ func (duoapi *DuoApi) SignedCall(method string, var requestBody io.ReadCloser = nil if method == "POST" || method == "PUT" { headers["Content-Type"] = "application/x-www-form-urlencoded" - requestBody = ioutil.NopCloser(strings.NewReader(params.Encode())) + requestBody = io.NopCloser(strings.NewReader(params.Encode())) } return duoapi.makeRetryableHttpCall(method, url, headers, requestBody, options...) } +type JSONParams map[string]any + +// Make a signed Duo Rest API call that takes JSON as an argument. +// 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 +// json is the JSON parameters to include in the call. +// options Optional parameters. Use UseTimeout to toggle whether the +// Duo Rest API call should timeout or not. +// +// Example: +// params := duoapi.JSONParams{ +// "user_id": userid, +// "activation_code": activationCode, +// } +// JSONSignedCall("POST", "/auth/v2/enroll_status", params, duoapi.UseTimeout) +func (duoapi *DuoApi) JSONSignedCall(method string, + uri string, + params JSONParams, + options ...DuoApiOption) (*http.Response, []byte, error) { + + body_methods := []string{"POST", "PUT", "PATCH"} + params_go_in_body := slices.Contains(body_methods, method) + + now := time.Now().UTC().Format(time.RFC1123Z) + var body string + api_url := url.URL{ + Scheme: "https", + Host: duoapi.host, + Path: uri, + } + + url_values := url.Values{} + if params_go_in_body { + body_bytes, _ := json.Marshal(params) + body = string(body_bytes[:]) + } else { + body = "" + var err error + url_values, err = jsonToValues(params) + if err == nil { + api_url.RawQuery = url_values.Encode() + } else { + return nil, nil, err + } + } + auth_sig := signV5(duoapi.ikey, duoapi.skey, method, duoapi.host, uri, now, url_values, body) + + method = strings.ToUpper(method) + headers := make(map[string]string) + headers["User-Agent"] = duoapi.userAgent + headers["Authorization"] = auth_sig + headers["Date"] = now + var requestBody io.ReadCloser = nil + if params_go_in_body { + headers["Content-Type"] = "application/json" + requestBody = io.NopCloser(strings.NewReader(body)) + } + + return duoapi.makeRetryableHttpCall(method, api_url, headers, requestBody, options...) +} + func (duoapi *DuoApi) makeRetryableHttpCall( method string, url url.URL, From 49ee5d2746f9dd04c181875bc6e8ae8da3637cd6 Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Thu, 24 Aug 2023 13:31:54 -0700 Subject: [PATCH 4/9] Add tests --- duo_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/duo_test.go b/duo_test.go index f1540d6..ac9dc6c 100644 --- a/duo_test.go +++ b/duo_test.go @@ -3,9 +3,10 @@ package duoapi import ( "bytes" "errors" - "io/ioutil" + "io" "net/http" "net/url" + "reflect" "strconv" "strings" "testing" @@ -131,7 +132,26 @@ func TestSign(t *testing.T) { } } -func TestV2Canonicalize(t *testing.T) { +func TestSignV5(t *testing.T) { + values := url.Values{} + values.Set("realname", "First Last") + body := "{\"txid\":\"f22b1678-252a-4070-b176-0ca2be7319fd\"}" + res := signV5("DIWJ8X6AEYOR5OMC6TQ1", + "Zh5eGmUq9zpfQnyUIu5OL9iWoMMv5ZNmk3zLJ4Ep", + "POST", + "api-XXXXXXXX.duosecurity.com", + "/accounts/v1/account/list", + "Tue, 21 Aug 2012 17:29:18 -0000", + values, + body) + expected := "Basic RElXSjhYNkFFWU9SNU9NQzZUUTE6NzhmNDMyN2Y4MzExNzNjYzc4ZDA5MDdlOTEzZTNjNWEyOGZlNzJkZDQ1NDVhMzQyNTg2YmI2NzE4MWYyYmEzOTNkMjA5MTFlODcwMzYyZjZmYWJhM2RjNmY3ZTlkYjVlOTNhZWQyZjNiZmMxMTBjNmRhZGFmZjRkYzYxNzllMGI=" + if res != expected { + t.Error("Mismatch between expected and received\n" + "Expected: " + expected + "\nReceived: " + res) + } + +} + +func TestCanonicalizeV2(t *testing.T) { values := url.Values{} values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰", "ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐") @@ -153,6 +173,29 @@ func TestV2Canonicalize(t *testing.T) { } } +func TestCanonicalizeV5(t *testing.T) { + values := url.Values{} + values.Set("username", "H ell?o") + body := "{\"activation_code\":\"duo://x1bTAIQGWXppdi2ctPVn-YXBpLWR1bzEuZHVvLnRlc3Q\",\"user_id\":\"DU439XKOX2W6LMYHWLEV\"}" + canon := canonicalizeV5( + "post", + "FOO.example.CoM", + "/Foo/BaR2/qux", + values, + body, + "Fri, 07 Dec 2012 17:18:00 -0000") + expected := `Fri, 07 Dec 2012 17:18:00 -0000 +POST +foo.example.com +/Foo/BaR2/qux +username=H%20ell%3Fo +9ddab7d898836a76fafbb0dcef7bc83f14036b39bbb1ebbe43044f7c76338fa699eba8f0d3ecb329a084f32ef23c8a1609efad25032923b651e6f3f6d2f7b773 +cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e` + if canon != expected { + t.Error("Mismatch between expected and received\n" + "Expected: " + expected + "\nReceived: " + canon) + } +} + func TestNewDuo(t *testing.T) { duo := NewDuoApi("ABC", "123", "api-XXXXXXX.duosecurity.com", "go-client") if duo == nil { @@ -174,7 +217,7 @@ func TestSetTransport(t *testing.T) { } } -func TestDupApiCallHttpErr(t *testing.T) { +func TestDuoApiCallHttpErr(t *testing.T) { httpClient := &mockHttpClient{doError: true} sleepSvc := &mockSleepService{} @@ -219,11 +262,11 @@ func getMockClients(httpResponses []http.Response) (*DuoApi, *mockHttpClient, *m var okResp = http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(bytes.NewReader([]byte("hello world"))), + Body: io.NopCloser(bytes.NewReader([]byte("hello world"))), } var rateLimitResp = http.Response{ StatusCode: 429, - Body: ioutil.NopCloser(bytes.NewReader([]byte("hello world"))), + Body: io.NopCloser(bytes.NewReader([]byte("hello world"))), } var completeRateLimitSleepDurations = []time.Duration{ @@ -312,6 +355,47 @@ func TestSignedCallCompletelyRateLimited(t *testing.T) { 7, rateLimitResp, completeRateLimitSleepDurations) } +func TestHashString(t *testing.T) { + body := `{"limit":10,"offset":2}` + expected := "66fabab062974c3dd3f4d27284e41bf8121d71c0e63e95631992062ef5d1a4058403af3482c8c32ae63cd724cbf0aa793a931ef273539ef6f3745751c22f25f6" + res := hashString(body) + if res != expected { + t.Error("Expected hash of body params but got:\n" + res) + } +} + +func TestJSONToValues(t *testing.T) { + json := JSONParams{ + "user_id": "1234", + "activation_code": "1234567890-abcdef", + } + expected := url.Values{ + "activation_code": []string{"1234567890-abcdef"}, + "user_id": []string{"1234"}, + } + res, _ := jsonToValues(json) + if !reflect.DeepEqual(res, expected) { + t.Error("Expected parsed JSON params but got:\n" + res.Encode()) + } + + empty_json := JSONParams{} + empty_expected := url.Values{} + empty_res, _ := jsonToValues(empty_json) + if !reflect.DeepEqual(empty_res, empty_expected) { + t.Error("Expected empty result but got:\n" + res.Encode()) + } + + bad_json := JSONParams{ + "user_id": 1234, + } + expected_err := "JSON value not a string" + _, err := jsonToValues(bad_json) + if err.Error() != expected_err { + t.Error("Expected not a string error but received " + err.Error()) + } + +} + type mockHttpClient struct { responses []http.Response actualRequests []*http.Request From 90021f878cadf824a9b2edd3034ff65c00a4970b Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Thu, 24 Aug 2023 14:05:44 -0700 Subject: [PATCH 5/9] Drop version back to 1.15 --- duoapi.go | 10 ++++++---- go.mod | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/duoapi.go b/duoapi.go index cb321e7..c7704d9 100644 --- a/duoapi.go +++ b/duoapi.go @@ -14,7 +14,6 @@ import ( "math/rand" "net/http" "net/url" - "slices" "sort" "strings" "time" @@ -344,7 +343,7 @@ func (duoapi *DuoApi) SignedCall(method string, return duoapi.makeRetryableHttpCall(method, url, headers, requestBody, options...) } -type JSONParams map[string]any +type JSONParams map[string]interface{} // Make a signed Duo Rest API call that takes JSON as an argument. // See Duo's online documentation for the available REST API's. @@ -365,8 +364,11 @@ func (duoapi *DuoApi) JSONSignedCall(method string, params JSONParams, options ...DuoApiOption) (*http.Response, []byte, error) { - body_methods := []string{"POST", "PUT", "PATCH"} - params_go_in_body := slices.Contains(body_methods, method) + body_methods := make(map[string]struct{}) + body_methods["POST"] = struct{}{} + body_methods["PUT"] = struct{}{} + body_methods["PATCH"] = struct{}{} + _, params_go_in_body := body_methods[method] now := time.Now().UTC().Format(time.RFC1123Z) var body string diff --git a/go.mod b/go.mod index 0e5fe3b..41e09dc 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/duosecurity/duo_api_golang -go 1.18 +go 1.15 \ No newline at end of file From b051cd349a1aeaa7b539f961a43d5b5c1aa98d3c Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Fri, 25 Aug 2023 10:46:11 -0700 Subject: [PATCH 6/9] Update comment --- duoapi.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/duoapi.go b/duoapi.go index c7704d9..82bb363 100644 --- a/duoapi.go +++ b/duoapi.go @@ -347,7 +347,7 @@ type JSONParams map[string]interface{} // Make a signed Duo Rest API call that takes JSON as an argument. // See Duo's online documentation for the available REST API's. -// method is POST or GET +// method is one of GET, POST, PATCH, PUT, DELETE // uri is the URI of the Duo Rest call // json is the JSON parameters to include in the call. // options Optional parameters. Use UseTimeout to toggle whether the From 91a16539a20e59b41edb66c8a231b1394d6287e4 Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Wed, 6 Sep 2023 08:35:22 -0700 Subject: [PATCH 7/9] Revert back to ioutil for 1.14.x support --- duoapi.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/duoapi.go b/duoapi.go index 82bb363..3ba7a42 100644 --- a/duoapi.go +++ b/duoapi.go @@ -337,7 +337,7 @@ func (duoapi *DuoApi) SignedCall(method string, var requestBody io.ReadCloser = nil if method == "POST" || method == "PUT" { headers["Content-Type"] = "application/x-www-form-urlencoded" - requestBody = io.NopCloser(strings.NewReader(params.Encode())) + requestBody = ioutil.NopCloser(strings.NewReader(params.Encode())) } return duoapi.makeRetryableHttpCall(method, url, headers, requestBody, options...) @@ -402,7 +402,7 @@ func (duoapi *DuoApi) JSONSignedCall(method string, var requestBody io.ReadCloser = nil if params_go_in_body { headers["Content-Type"] = "application/json" - requestBody = io.NopCloser(strings.NewReader(body)) + requestBody = ioutil.NopCloser(strings.NewReader(body)) } return duoapi.makeRetryableHttpCall(method, api_url, headers, requestBody, options...) From 8620e19d2cca960a33ab1207858d23253e963bed Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Fri, 2 Feb 2024 09:50:41 -0800 Subject: [PATCH 8/9] Better error handling in signed call --- duoapi.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/duoapi.go b/duoapi.go index 3ba7a42..5932b59 100644 --- a/duoapi.go +++ b/duoapi.go @@ -380,18 +380,21 @@ func (duoapi *DuoApi) JSONSignedCall(method string, url_values := url.Values{} if params_go_in_body { - body_bytes, _ := json.Marshal(params) + body_bytes, err := json.Marshal(params) + if err != nil { + return nil, nil, err + } body = string(body_bytes[:]) } else { body = "" var err error url_values, err = jsonToValues(params) - if err == nil { - api_url.RawQuery = url_values.Encode() - } else { + if err != nil { return nil, nil, err } + api_url.RawQuery = url_values.Encode() } + auth_sig := signV5(duoapi.ikey, duoapi.skey, method, duoapi.host, uri, now, url_values, body) method = strings.ToUpper(method) From 198e2b64f158702689de0c9e0cfbbcb155345b18 Mon Sep 17 00:00:00 2001 From: Jonathan Aherne Date: Fri, 2 Feb 2024 11:28:17 -0800 Subject: [PATCH 9/9] Fix tests for go versions < 1.16 --- duo_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/duo_test.go b/duo_test.go index ac9dc6c..ecd9f05 100644 --- a/duo_test.go +++ b/duo_test.go @@ -3,7 +3,7 @@ package duoapi import ( "bytes" "errors" - "io" + "io/ioutil" "net/http" "net/url" "reflect" @@ -262,11 +262,11 @@ func getMockClients(httpResponses []http.Response) (*DuoApi, *mockHttpClient, *m var okResp = http.Response{ StatusCode: 200, - Body: io.NopCloser(bytes.NewReader([]byte("hello world"))), + Body: ioutil.NopCloser(bytes.NewReader([]byte("hello world"))), } var rateLimitResp = http.Response{ StatusCode: 429, - Body: io.NopCloser(bytes.NewReader([]byte("hello world"))), + Body: ioutil.NopCloser(bytes.NewReader([]byte("hello world"))), } var completeRateLimitSleepDurations = []time.Duration{