Skip to content

Commit

Permalink
Merge pull request #48 from jaherne-duo/sigv5-support
Browse files Browse the repository at this point in the history
Add support for v5 signatures
  • Loading branch information
AaronAtDuo authored Feb 5, 2024
2 parents 096d330 + 198e2b6 commit bb361ad
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 10 deletions.
4 changes: 2 additions & 2 deletions authapi/authapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"net/url"
"strconv"

"github.com/duosecurity/duo_api_golang"
duoapi "github.com/duosecurity/duo_api_golang"
)

type AuthApi struct {
Expand Down Expand Up @@ -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.
Expand Down
88 changes: 86 additions & 2 deletions duo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io/ioutil"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -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("䚚⡻㗐軳朧倪ࠐ킑È셰",
"ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐")
Expand All @@ -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 {
Expand All @@ -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{}

Expand Down Expand Up @@ -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
Expand Down
133 changes: 128 additions & 5 deletions duoapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"io/ioutil"
"math/rand"
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/duosecurity/duo_api_golang

go 1.15
go 1.15

0 comments on commit bb361ad

Please sign in to comment.