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. diff --git a/duo_test.go b/duo_test.go index f1540d6..ecd9f05 100644 --- a/duo_test.go +++ b/duo_test.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "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{} @@ -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 diff --git a/duoapi.go b/duoapi.go index c4ba5f7..5932b59 100644 --- a/duoapi.go +++ b/duoapi.go @@ -7,6 +7,8 @@ import ( "crypto/x509" "encoding/base64" "encoding/hex" + "encoding/json" + "errors" "io" "io/ioutil" "math/rand" @@ -54,6 +56,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 +107,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 +188,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 +282,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 +308,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, @@ -288,6 +343,74 @@ func (duoapi *DuoApi) SignedCall(method string, return duoapi.makeRetryableHttpCall(method, url, headers, requestBody, options...) } +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 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 +// 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 := 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 + api_url := url.URL{ + Scheme: "https", + Host: duoapi.host, + Path: uri, + } + + url_values := url.Values{} + if params_go_in_body { + 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 { + 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) + 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 = ioutil.NopCloser(strings.NewReader(body)) + } + + return duoapi.makeRetryableHttpCall(method, api_url, headers, requestBody, options...) +} + func (duoapi *DuoApi) makeRetryableHttpCall( method string, url url.URL, diff --git a/go.mod b/go.mod index c42635e..41e09dc 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/duosecurity/duo_api_golang -go 1.15 +go 1.15 \ No newline at end of file