diff --git a/Gopkg.lock b/Gopkg.lock index b9aeb57..aa6dec8 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -37,6 +37,17 @@ revision = "3cfea5ab600ae37946be2b763b8ec2c1cf2d272d" version = "v1.0.0" +[[projects]] + branch = "master" + digest = "1:6911d618517b98a7b59bf3f244ce648a29c81e2c4413f2cda9224a1d2a9b4840" + name = "github.com/duosecurity/duo_api_golang" + packages = [ + ".", + "authapi", + ] + pruneopts = "" + revision = "61e0defebf22863592a27013b399685d0def12b6" + [[projects]] branch = "master" digest = "1:b7eb152b61f41f0c9bcb6cf1187e1470a91d5e01d1e08f8b7bfd722578851720" @@ -183,6 +194,8 @@ "github.com/GeertJohan/yubigo", "github.com/Luzifer/go_helpers/str", "github.com/Luzifer/rconfig", + "github.com/duosecurity/duo_api_golang", + "github.com/duosecurity/duo_api_golang/authapi", "github.com/flosch/pongo2", "github.com/gorilla/context", "github.com/gorilla/sessions", diff --git a/README.md b/README.md index 0a17804..609875f 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,26 @@ attributes: ``` +#### Duo + +This provider needs a configuration to function correctly: + +```yaml +mfa: + duo: + # Get your ikey / skey / host from https://duo.com/docs/duoweb#first-steps + ikey: "" + skey: "" + host: "" + user_agent: "nginx-sso" +``` + +The corresponding expected MFA configuration is as following: + +```yaml +provider: duo +``` + #### Google Authenticator The provider name here is `google` while the only supported argument at the moment is `secret`. The secret is what you need to provide to your users for them to add the config to their authenticator. (It MUST be base32 encoded!) diff --git a/config.yaml b/config.yaml index c8628c1..10f89d8 100644 --- a/config.yaml +++ b/config.yaml @@ -43,6 +43,12 @@ mfa: client_id: "12345" secret_key: "foobar" + duo: + ikey: "IKEY" + skey: "SKEY" + host: "HOST" + user_agent: "nginx-sso" + providers: # Authentication against an Atlassian Crowd directory server # Supports: Users, Groups @@ -96,6 +102,7 @@ providers: # MFA configs: Username to configs mapping mfa: luzifer: + - provider: duo - provider: google attributes: secret: MZXW6YTBOIFA diff --git a/mfa_duo.go b/mfa_duo.go new file mode 100644 index 0000000..089ee31 --- /dev/null +++ b/mfa_duo.go @@ -0,0 +1,131 @@ +package main + +import ( + "net" + "net/http" + "strings" + "time" + + duoapi "github.com/duosecurity/duo_api_golang" + "github.com/duosecurity/duo_api_golang/authapi" + "github.com/pkg/errors" + yaml "gopkg.in/yaml.v2" +) + +const ( + mfaDuoResponseAllow = "allow" + mfaDuoRequestTimeout = 10 * time.Second +) + +var mfaDuoTrustedIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"} + +func init() { + registerMFAProvider(&mfaDuo{}) +} + +type mfaDuo struct { + IKey string `yaml:"ikey"` + SKey string `yaml:"skey"` + Host string `yaml:"host"` + UserAgent string `yaml:"user_agent"` +} + +// ProviderID needs to return an unique string to identify +// this special MFA provider +func (m mfaDuo) ProviderID() (id string) { return "duo" } + +// Configure loads the configuration for the Authenticator from the +// global config.yaml file which is passed as a byte-slice. +// If no configuration for the Authenticator is supplied the function +// needs to return the errProviderUnconfigured +func (m *mfaDuo) Configure(yamlSource []byte) (err error) { + envelope := struct { + MFA struct { + Duo *mfaDuo `yaml:"duo"` + } `yaml:"mfa"` + }{} + + if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { + return err + } + + if envelope.MFA.Duo == nil { + return errProviderUnconfigured + } + + m.IKey = envelope.MFA.Duo.IKey + m.SKey = envelope.MFA.Duo.SKey + m.Host = envelope.MFA.Duo.Host + m.UserAgent = envelope.MFA.Duo.UserAgent + return nil +} + +// ValidateMFA takes the user from the login cookie and performs a +// validation against the provided MFA configuration for this user +func (m mfaDuo) ValidateMFA(res http.ResponseWriter, r *http.Request, user string, mfaCfgs []mfaConfig) error { + var keyInput string + + // Look for mfaConfigs with own provider name + for _, c := range mfaCfgs { + if c.Provider != m.ProviderID() { + continue + } + + remoteIP, err := m.findIP(r) + if err != nil { + return errors.Wrap(err, "Unable to determine remote IP") + } + + duo := authapi.NewAuthApi(*duoapi.NewDuoApi(m.IKey, m.SKey, m.Host, m.UserAgent, duoapi.SetTimeout(mfaDuoRequestTimeout))) + + for key, values := range r.Form { + if strings.HasSuffix(key, mfaLoginFieldName) && len(values[0]) > 0 { + keyInput = values[0] + } + } + + // Check if MFA token provided and fallover to push if not supplied + var auth *authapi.AuthResult + + if keyInput != "" { + if auth, err = duo.Auth("passcode", authapi.AuthUsername(user), authapi.AuthPasscode(keyInput), authapi.AuthIpAddr(remoteIP)); err != nil { + return errors.Wrap(err, "Unable to authenticate with Duo using 'passcode' method") + } + } else { + if auth, err = duo.Auth("auto", authapi.AuthUsername(user), authapi.AuthDevice("auto"), authapi.AuthIpAddr(remoteIP)); err != nil { + return errors.Wrap(err, "Unable to authenticate with Duo using 'auto' method") + } + } + + if auth.Response.Result == mfaDuoResponseAllow { + return nil + } + } + + // Report this provider was not able to verify the MFA request + return errNoValidUserFound +} + +func (m mfaDuo) findIP(r *http.Request) (string, error) { + for _, hdr := range mfaDuoTrustedIPHeaders { + if value := r.Header.Get(hdr); value != "" { + return m.parseIP(value) + } + } + + return m.parseIP(r.RemoteAddr) +} + +func (m mfaDuo) parseIP(s string) (string, error) { + ip, _, err := net.SplitHostPort(s) + if err == nil { + return ip, nil + } + + ip2 := net.ParseIP(s) + if ip2 == nil { + return "", errors.New("invalid IP") + } + + return ip2.String(), nil +} diff --git a/vendor/github.com/duosecurity/duo_api_golang/LICENSE b/vendor/github.com/duosecurity/duo_api_golang/LICENSE new file mode 100644 index 0000000..2510e98 --- /dev/null +++ b/vendor/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/vendor/github.com/duosecurity/duo_api_golang/README.md b/vendor/github.com/duosecurity/duo_api_golang/README.md new file mode 100644 index 0000000..6afeff5 --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/README.md @@ -0,0 +1,19 @@ +# Overview + +**duo_api_golang** - Go language bindings for the Duo APIs (both auth and admin). + +## Duo Auth API + +The Auth API is a low-level, RESTful API for adding strong two-factor authentication to your website or application. + +This module's API client implementation is *complete*; corresponding methods are exported for all available endpoints. + +For more information see the [Auth API guide](https://duo.com/docs/authapi). + +## Duo Admin API + +The Admin API provides programmatic access to the administrative functionality of Duo Security's two-factor authentication platform. + +This module's API client implementation is *incomplete*; methods for fetching most entity types are exported, but methods that modify entities have (mostly) not yet been implemented. PRs welcome! + +For more information see the [Admin API guide](https://duo.com/docs/adminapi). diff --git a/vendor/github.com/duosecurity/duo_api_golang/admin/admin.go b/vendor/github.com/duosecurity/duo_api_golang/admin/admin.go new file mode 100644 index 0000000..71f26fb --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/admin/admin.go @@ -0,0 +1,722 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/duosecurity/duo_api_golang" +) + +// Client provides access to Duo's admin API. +type Client struct { + duoapi.DuoApi +} + +type ListResultMetadata struct { + NextOffset json.Number `json:"next_offset"` + PrevOffset json.Number `json:"prev_offset"` + TotalObjects json.Number `json:"total_objects"` +} + +type ListResult struct { + Metadata ListResultMetadata `json:"metadata"` +} + +func (l *ListResult) metadata() ListResultMetadata { + return l.Metadata +} + +// New initializes an admin API Client struct. +func New(base duoapi.DuoApi) *Client { + return &Client{base} +} + +// User models a single user. +type User struct { + Alias1 *string + Alias2 *string + Alias3 *string + Alias4 *string + Created uint64 + Email string + FirstName *string + Groups []Group + LastDirectorySync *uint64 `json:"last_directory_sync"` + LastLogin *uint64 `json:"last_login"` + LastName *string + Notes string + Phones []Phone + RealName *string + Status string + Tokens []Token + UserID string `json:"user_id"` + Username string +} + +// Group models a group to which users may belong. +type Group struct { + Desc string + GroupID string `json:"group_id"` + MobileOTPEnabled bool `json:"mobile_otp_enabled"` + Name string + PushEnabled bool `json:"push_enabled"` + SMSEnabled bool `json:"sms_enabled"` + Status string + VoiceEnabled bool `json:"voice_enabled"` +} + +// Phone models a user's phone. +type Phone struct { + Activated bool + Capabilities []string + Encrypted string + Extension string + Fingerprint string + Name string + Number string + PhoneID string `json:"phone_id"` + Platform string + Postdelay string + Predelay string + Screenlock string + SMSPasscodesSent bool + Type string + Users []User +} + +// Token models a hardware security token. +type Token struct { + TokenID string `json:"token_id"` + Type string + Serial string + TOTPStep *int `json:"totp_step"` + Users []User +} + +// U2FToken models a U2F security token. +type U2FToken struct { + DateAdded uint64 `json:"date_added"` + RegistrationID string `json:"registration_id"` + User *User +} + +// Common URL options + +// Limit sets the optional limit parameter for an API request. +func Limit(limit uint64) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("limit", strconv.FormatUint(limit, 10)) + } +} + +// Offset sets the optional offset parameter for an API request. +func Offset(offset uint64) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("offset", strconv.FormatUint(offset, 10)) + } +} + +// User methods + +// GetUsersUsername sets the optional username parameter for a GetUsers request. +func GetUsersUsername(name string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("username", name) + } +} + +// GetUsersResult models responses containing a list of users. +type GetUsersResult struct { + duoapi.StatResult + ListResult + Response []User +} + +func (result *GetUsersResult) getResponse() interface{} { + return result.Response +} + +func (result *GetUsersResult) appendResponse(users interface{}) { + asserted_users := users.([]User) + result.Response = append(result.Response, asserted_users...) +} + +// GetUsers calls GET /admin/v1/users +// See https://duo.com/docs/adminapi#retrieve-users +func (c *Client) GetUsers(options ...func(*url.Values)) (*GetUsersResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveUsers(params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetUsersResult), nil +} + +type responsePage interface { + metadata() ListResultMetadata + getResponse() interface{} + appendResponse(interface{}) +} + +type pageFetcher func(params url.Values) (responsePage, error) + +func (c *Client) retrieveItems( + params url.Values, + fetcher pageFetcher, +) (responsePage, error) { + if params.Get("offset") == "" { + params.Set("offset", "0") + } + + if params.Get("limit") == "" { + params.Set("limit", "100") + accumulator, firstErr := fetcher(params) + + if firstErr != nil { + return nil, firstErr + } + + params.Set("offset", accumulator.metadata().NextOffset.String()) + for ; params.Get("offset") != "" ; { + nextResult, err := fetcher(params) + if err != nil { + return nil, err + } + nextResult.appendResponse(accumulator.getResponse()) + accumulator = nextResult + params.Set("offset", accumulator.metadata().NextOffset.String()) + } + return accumulator, nil + } + + return fetcher(params) +} + +func (c *Client) retrieveUsers(params url.Values) (*GetUsersResult, error) { + _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/users", params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetUsersResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetUser calls GET /admin/v1/users/:user_id +// See https://duo.com/docs/adminapi#retrieve-user-by-id +func (c *Client) GetUser(userID string) (*GetUsersResult, error) { + path := fmt.Sprintf("/admin/v1/users/%s", userID) + + _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetUsersResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetUserGroups calls GET /admin/v1/users/:user_id/groups +// See https://duo.com/docs/adminapi#retrieve-groups-by-user-id +func (c *Client) GetUserGroups(userID string, options ...func(*url.Values)) (*GetGroupsResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveUserGroups(userID, params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetGroupsResult), nil +} + +func (c *Client) retrieveUserGroups(userID string, params url.Values) (*GetGroupsResult, error) { + path := fmt.Sprintf("/admin/v1/users/%s/groups", userID) + + _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetGroupsResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetUserPhones calls GET /admin/v1/users/:user_id/phones +// See https://duo.com/docs/adminapi#retrieve-phones-by-user-id +func (c *Client) GetUserPhones(userID string, options ...func(*url.Values)) (*GetPhonesResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveUserPhones(userID, params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetPhonesResult), nil +} + +func (c *Client) retrieveUserPhones(userID string, params url.Values) (*GetPhonesResult, error) { + path := fmt.Sprintf("/admin/v1/users/%s/phones", userID) + + _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetPhonesResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetUserTokens calls GET /admin/v1/users/:user_id/tokens +// See https://duo.com/docs/adminapi#retrieve-hardware-tokens-by-user-id +func (c *Client) GetUserTokens(userID string, options ...func(*url.Values)) (*GetTokensResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveUserTokens(userID, params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetTokensResult), nil +} + +func (c *Client) retrieveUserTokens(userID string, params url.Values) (*GetTokensResult, error) { + path := fmt.Sprintf("/admin/v1/users/%s/tokens", userID) + + _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetTokensResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// StringResult models responses containing a simple string. +type StringResult struct { + duoapi.StatResult + Response string +} + +// AssociateUserToken calls POST /admin/v1/users/:user_id/tokens +// See https://duo.com/docs/adminapi#associate-hardware-token-with-user +func (c *Client) AssociateUserToken(userID, tokenID string) (*StringResult, error) { + path := fmt.Sprintf("/admin/v1/users/%s/tokens", userID) + + params := url.Values{} + params.Set("token_id", tokenID) + + _, body, err := c.SignedCall(http.MethodPost, path, params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &StringResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetUserU2FTokens calls GET /admin/v1/users/:user_id/u2ftokens +// See https://duo.com/docs/adminapi#retrieve-u2f-tokens-by-user-id +func (c *Client) GetUserU2FTokens(userID string, options ...func(*url.Values)) (*GetU2FTokensResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveUserU2FTokens(userID, params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetU2FTokensResult), nil +} + +func (c *Client) retrieveUserU2FTokens(userID string, params url.Values) (*GetU2FTokensResult, error) { + path := fmt.Sprintf("/admin/v1/users/%s/u2ftokens", userID) + + _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetU2FTokensResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// Group methods + +// GetGroupsResult models responses containing a list of groups. +type GetGroupsResult struct { + duoapi.StatResult + ListResult + Response []Group +} + +func (result *GetGroupsResult) getResponse() interface{} { + return result.Response +} + +func (result *GetGroupsResult) appendResponse(groups interface{}) { + asserted_groups := groups.([]Group) + result.Response = append(result.Response, asserted_groups...) +} + +// GetGroups calls GET /admin/v1/groups +// See https://duo.com/docs/adminapi#retrieve-groups +func (c *Client) GetGroups(options ...func(*url.Values)) (*GetGroupsResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveGroups(params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetGroupsResult), nil +} + +func (c *Client) retrieveGroups(params url.Values) (*GetGroupsResult, error) { + _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/groups", params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetGroupsResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetGroupResult models responses containing a single group. +type GetGroupResult struct { + duoapi.StatResult + Response Group +} + +// GetGroup calls GET /admin/v2/group/:group_id +// See https://duo.com/docs/adminapi#get-group-info +func (c *Client) GetGroup(groupID string) (*GetGroupResult, error) { + path := fmt.Sprintf("/admin/v2/groups/%s", groupID) + + _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetGroupResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// Phone methods + +// GetPhonesNumber sets the optional number parameter for a GetPhones request. +func GetPhonesNumber(number string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("number", number) + } +} + +// GetPhonesExtension sets the optional extension parameter for a GetPhones request. +func GetPhonesExtension(ext string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("extension", ext) + } +} + +// GetPhonesResult models responses containing a list of phones. +type GetPhonesResult struct { + duoapi.StatResult + ListResult + Response []Phone +} + +func (result *GetPhonesResult) getResponse() interface{} { + return result.Response +} + +func (result *GetPhonesResult) appendResponse(phones interface{}) { + asserted_phones := phones.([]Phone) + result.Response = append(result.Response, asserted_phones...) +} + + +// GetPhones calls GET /admin/v1/phones +// See https://duo.com/docs/adminapi#phones +func (c *Client) GetPhones(options ...func(*url.Values)) (*GetPhonesResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrievePhones(params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetPhonesResult), nil +} + +func (c *Client) retrievePhones(params url.Values) (*GetPhonesResult, error) { + _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/phones", params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetPhonesResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetPhoneResult models responses containing a single phone. +type GetPhoneResult struct { + duoapi.StatResult + Response Phone +} + +// GetPhone calls GET /admin/v1/phones/:phone_id +// See https://duo.com/docs/adminapi#retrieve-phone-by-id +func (c *Client) GetPhone(phoneID string) (*GetPhoneResult, error) { + path := fmt.Sprintf("/admin/v1/phones/%s", phoneID) + + _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetPhoneResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// Token methods + +// GetTokensTypeAndSerial sets the optional type and serial parameters for a GetTokens request. +func GetTokensTypeAndSerial(typ, serial string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("type", typ) + opts.Set("serial", serial) + } +} + +// GetTokensResult models responses containing a list of tokens. +type GetTokensResult struct { + duoapi.StatResult + ListResult + Response []Token +} + +func (result *GetTokensResult) getResponse() interface{} { + return result.Response +} + +func (result *GetTokensResult) appendResponse(tokens interface{}) { + asserted_tokens := tokens.([]Token) + result.Response = append(result.Response, asserted_tokens...) +} + + +// GetTokens calls GET /admin/v1/tokens +// See https://duo.com/docs/adminapi#retrieve-hardware-tokens +func (c *Client) GetTokens(options ...func(*url.Values)) (*GetTokensResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveTokens(params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetTokensResult), nil +} + +func (c *Client) retrieveTokens(params url.Values) (*GetTokensResult, error) { + _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/tokens", params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetTokensResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetTokenResult models responses containing a single token. +type GetTokenResult struct { + duoapi.StatResult + Response Token +} + +// GetToken calls GET /admin/v1/tokens/:token_id +// See https://duo.com/docs/adminapi#retrieve-hardware-tokens +func (c *Client) GetToken(tokenID string) (*GetTokenResult, error) { + path := fmt.Sprintf("/admin/v1/tokens/%s", tokenID) + + _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetTokenResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// U2F token methods + +// GetU2FTokensResult models responses containing a list of U2F tokens. +type GetU2FTokensResult struct { + duoapi.StatResult + ListResult + Response []U2FToken +} + +func (result *GetU2FTokensResult) getResponse() interface{} { + return result.Response +} + +func (result *GetU2FTokensResult) appendResponse(tokens interface{}) { + asserted_tokens := tokens.([]U2FToken) + result.Response = append(result.Response, asserted_tokens...) +} + + +// GetU2FTokens calls GET /admin/v1/u2ftokens +// See https://duo.com/docs/adminapi#retrieve-u2f-tokens +func (c *Client) GetU2FTokens(options ...func(*url.Values)) (*GetU2FTokensResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + + cb := func(params url.Values) (responsePage, error) { + return c.retrieveU2FTokens(params) + } + response, err := c.retrieveItems(params, cb) + if err != nil { + return nil, err + } + + return response.(*GetU2FTokensResult), nil +} + +func (c *Client) retrieveU2FTokens(params url.Values) (*GetU2FTokensResult, error) { + _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/u2ftokens", params, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetU2FTokensResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} + +// GetU2FToken calls GET /admin/v1/u2ftokens/:registration_id +// See https://duo.com/docs/adminapi#retrieve-u2f-token-by-id +func (c *Client) GetU2FToken(registrationID string) (*GetU2FTokensResult, error) { + path := fmt.Sprintf("/admin/v1/u2ftokens/%s", registrationID) + + _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + + result := &GetU2FTokensResult{} + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/vendor/github.com/duosecurity/duo_api_golang/admin/admin_test.go b/vendor/github.com/duosecurity/duo_api_golang/admin/admin_test.go new file mode 100644 index 0000000..dcd9673 --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/admin/admin_test.go @@ -0,0 +1,1949 @@ +package admin + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/duosecurity/duo_api_golang" +) + +func buildAdminClient(url string, proxy func(*http.Request) (*url.URL, error)) *Client { + ikey := "eyekey" + skey := "esskey" + host := strings.Split(url, "//")[1] + userAgent := "GoTestClient" + base := duoapi.NewDuoApi(ikey, skey, host, userAgent, duoapi.SetTimeout(1*time.Second), duoapi.SetInsecure(), duoapi.SetProxy(proxy)) + return New(*base) +} + +func getBodyParams(r *http.Request) (url.Values, error) { + body, err := ioutil.ReadAll(r.Body) + r.Body.Close() + if err != nil { + return url.Values{}, err + } + reqParams, err := url.ParseQuery(string(body)) + return reqParams, err +} + +const getUsersResponse = `{ + "stat": "OK", + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 1 + }, + "response": [{ + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "created": 1489612729, + "email": "jsmith@example.com", + "firstname": "Joe", + "groups": [{ + "desc": "People with hardware tokens", + "name": "token_users" + }], + "last_directory_sync": 1508789163, + "last_login": 1343921403, + "lastname": "Smith", + "notes": "", + "phones": [{ + "phone_id": "DPFZRS9FB0D46QFTM899", + "number": "+15555550100", + "extension": "", + "name": "", + "postdelay": null, + "predelay": null, + "type": "Mobile", + "capabilities": [ + "sms", + "phone", + "push" + ], + "platform": "Apple iOS", + "activated": false, + "sms_passcodes_sent": false + }], + "realname": "Joe Smith", + "status": "active", + "tokens": [{ + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1" + }], + "user_id": "DU3RP9I2WOC59VZX672N", + "username": "jsmith" + }] +}` + +func TestGetUsers(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getUsersResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUsers() + if err != nil { + t.Errorf("Unexpected error from GetUsers call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 user, but got %d", len(result.Response)) + } + if result.Response[0].UserID != "DU3RP9I2WOC59VZX672N" { + t.Errorf("Expected user ID DU3RP9I2WOC59VZX672N, but got %s", result.Response[0].UserID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getUsersPage1Response = `{ + "stat": "OK", + "metadata": { + "prev_offset": null, + "next_offset": 1, + "total_objects": 2 + }, + "response": [{ + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "created": 1489612729, + "email": "jsmith@example.com", + "firstname": "Joe", + "groups": [{ + "desc": "People with hardware tokens", + "name": "token_users" + }], + "last_directory_sync": 1508789163, + "last_login": 1343921403, + "lastname": "Smith", + "notes": "", + "phones": [{ + "phone_id": "DPFZRS9FB0D46QFTM899", + "number": "+15555550100", + "extension": "", + "name": "", + "postdelay": null, + "predelay": null, + "type": "Mobile", + "capabilities": [ + "sms", + "phone", + "push" + ], + "platform": "Apple iOS", + "activated": false, + "sms_passcodes_sent": false + }], + "realname": "Joe Smith", + "status": "active", + "tokens": [{ + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1" + }], + "user_id": "DU3RP9I2WOC59VZX672N", + "username": "jsmith" + }] +}` + +const getUsersPage2Response = `{ + "stat": "OK", + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 2 + }, + "response": [{ + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "created": 1489612729, + "email": "jsmith@example.com", + "firstname": "Joe", + "groups": [{ + "desc": "People with hardware tokens", + "name": "token_users" + }], + "last_directory_sync": 1508789163, + "last_login": 1343921403, + "lastname": "Smith", + "notes": "", + "phones": [{ + "phone_id": "DPFZRS9FB0D46QFTM899", + "number": "+15555550100", + "extension": "", + "name": "", + "postdelay": null, + "predelay": null, + "type": "Mobile", + "capabilities": [ + "sms", + "phone", + "push" + ], + "platform": "Apple iOS", + "activated": false, + "sms_passcodes_sent": false + }], + "realname": "Joe Smith", + "status": "active", + "tokens": [{ + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1" + }], + "user_id": "DU3RP9I2WOC59VZX672N", + "username": "jsmith" + }] +}` + +func TestGetUsersMultipage(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getUsersPage1Response) + } else { + fmt.Fprintln(w, getUsersPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUsers() + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if result.Metadata.TotalObjects != "2" { + t.Errorf("Expected total obects to be two, found %s", result.Metadata.TotalObjects) + } + + if len(result.Response) != 2 { + t.Errorf("Expected two users in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +const getEmptyPageArgsResponse = `{ + "stat": "OK", + "metadata": { + "prev_offset": null, + "next_offset": 2, + "total_objects": 2 + }, + "response": [] +}` + +func TestGetUserPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetUsers(func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +func TestGetUser(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getUsersResponse) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUser("DU3RP9I2WOC59VZX672N") + if err != nil { + t.Errorf("Unexpected error from GetUser call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 user, but got %d", len(result.Response)) + } + if result.Response[0].UserID != "DU3RP9I2WOC59VZX672N" { + t.Errorf("Expected user ID DU3RP9I2WOC59VZX672N, but got %s", result.Response[0].UserID) + } +} + +const getGroupsResponse = `{ + "response": [{ + "desc": "This is group A", + "group_id": "DGXXXXXXXXXXXXXXXXXA", + "name": "Group A", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }, + { + "desc": "This is group B", + "group_id": "DGXXXXXXXXXXXXXXXXXB", + "name": "Group B", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }], + "stat": "OK", + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetUserGroups(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getGroupsResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserGroups("DU3RP9I2WOC59VZX672N") + if err != nil { + t.Errorf("Unexpected error from GetUserGroups call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 2 { + t.Errorf("Expected 2 groups, but got %d", len(result.Response)) + } + if result.Response[0].GroupID != "DGXXXXXXXXXXXXXXXXXA" { + t.Errorf("Expected group ID DGXXXXXXXXXXXXXXXXXA, but got %s", result.Response[0].GroupID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getGroupsPage1Response = `{ + "response": [{ + "desc": "This is group A", + "group_id": "DGXXXXXXXXXXXXXXXXXA", + "name": "Group A", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }, + { + "desc": "This is group B", + "group_id": "DGXXXXXXXXXXXXXXXXXB", + "name": "Group B", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }], + "stat": "OK", + "metadata": { + "prev_offset": null, + "next_offset": 2, + "total_objects": 4 + } +}` + +const getGroupsPage2Response = `{ + "response": [{ + "desc": "This is group C", + "group_id": "DGXXXXXXXXXXXXXXXXXC", + "name": "Group C", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }, + { + "desc": "This is group D", + "group_id": "DGXXXXXXXXXXXXXXXXXD", + "name": "Group D", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }], + "stat": "OK", + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 4 + } +}` + +func TestGetUserGroupsMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getGroupsPage1Response) + } else { + fmt.Fprintln(w, getGroupsPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserGroups("DU3RP9I2WOC59VZX672N") + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 4 { + t.Errorf("Expected four groups in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetUserGroupsPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetUserGroups("DU3RP9I2WOC59VZX672N", func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getUserPhonesResponse = `{ + "stat": "OK", + "response": [{ + "activated": false, + "capabilities": [ + "sms", + "phone", + "push" + ], + "extension": "", + "name": "", + "number": "+15035550102", + "phone_id": "DPFZRS9FB0D46QFTM890", + "platform": "Apple iOS", + "postdelay": null, + "predelay": null, + "sms_passcodes_sent": false, + "type": "Mobile" + }, + { + "activated": false, + "capabilities": [ + "phone" + ], + "extension": "", + "name": "", + "number": "+15035550103", + "phone_id": "DPFZRS9FB0D46QFTM891", + "platform": "Unknown", + "postdelay": null, + "predelay": null, + "sms_passcodes_sent": false, + "type": "Landline" + }], + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetUserPhones(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getUserPhonesResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserPhones("DU3RP9I2WOC59VZX672N") + if err != nil { + t.Errorf("Unexpected error from GetUserPhones call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 2 { + t.Errorf("Expected 2 phones, but got %d", len(result.Response)) + } + if result.Response[0].PhoneID != "DPFZRS9FB0D46QFTM890" { + t.Errorf("Expected phone ID DPFZRS9FB0D46QFTM890, but got %s", result.Response[0].PhoneID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getUserPhonesPage1Response = `{ + "stat": "OK", + "response": [{ + "activated": false, + "capabilities": [ + "sms", + "phone", + "push" + ], + "extension": "", + "name": "", + "number": "+15035550102", + "phone_id": "DPFZRS9FB0D46QFTM890", + "platform": "Apple iOS", + "postdelay": null, + "predelay": null, + "sms_passcodes_sent": false, + "type": "Mobile" + }, + { + "activated": false, + "capabilities": [ + "phone" + ], + "extension": "", + "name": "", + "number": "+15035550103", + "phone_id": "DPFZRS9FB0D46QFTM891", + "platform": "Unknown", + "postdelay": null, + "predelay": null, + "sms_passcodes_sent": false, + "type": "Landline" + }], + "metadata": { + "prev_offset": null, + "next_offset": 2, + "total_objects": 4 + } +}` + +const getUserPhonesPage2Response = `{ + "stat": "OK", + "response": [{ + "activated": false, + "capabilities": [ + "sms", + "phone", + "push" + ], + "extension": "", + "name": "", + "number": "+15035550102", + "phone_id": "DPFZRS9FB0D46QFTM890", + "platform": "Apple iOS", + "postdelay": null, + "predelay": null, + "sms_passcodes_sent": false, + "type": "Mobile" + }, + { + "activated": false, + "capabilities": [ + "phone" + ], + "extension": "", + "name": "", + "number": "+15035550103", + "phone_id": "DPFZRS9FB0D46QFTM891", + "platform": "Unknown", + "postdelay": null, + "predelay": null, + "sms_passcodes_sent": false, + "type": "Landline" + }], + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 4 + } +}` + +func TestGetUserPhonesMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getUserPhonesPage1Response) + } else { + fmt.Fprintln(w, getUserPhonesPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserPhones("DU3RP9I2WOC59VZX672N") + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 4 { + t.Errorf("Expected four phones in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetUserPhonesPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetUserPhones("DU3RP9I2WOC59VZX672N", func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getUserTokensResponse = `{ + "stat": "OK", + "response": [{ + "type": "d1", + "serial": "0", + "token_id": "DHEKH0JJIYC1LX3AZWO4" + }, + { + "type": "d1", + "serial": "7", + "token_id": "DHUNT3ZVS3ACF8AEV2WG", + "totp_step": null + }], + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetUserTokens(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getUserTokensResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserTokens("DU3RP9I2WOC59VZX672N") + if err != nil { + t.Errorf("Unexpected error from GetUserTokens call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 2 { + t.Errorf("Expected 2 tokens, but got %d", len(result.Response)) + } + if result.Response[0].TokenID != "DHEKH0JJIYC1LX3AZWO4" { + t.Errorf("Expected token ID DHEKH0JJIYC1LX3AZWO4, but got %s", result.Response[0].TokenID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getUserTokensPage1Response = `{ + "stat": "OK", + "response": [{ + "type": "d1", + "serial": "0", + "token_id": "DHEKH0JJIYC1LX3AZWO4" + }, + { + "type": "d1", + "serial": "7", + "token_id": "DHUNT3ZVS3ACF8AEV2WG", + "totp_step": null + }], + "metadata": { + "prev_offset": null, + "next_offset": 2, + "total_objects": 4 + } +}` + +const getUserTokensPage2Response = `{ + "stat": "OK", + "response": [{ + "type": "d1", + "serial": "0", + "token_id": "DHEKH0JJIYC1LX3AZWO4" + }, + { + "type": "d1", + "serial": "7", + "token_id": "DHUNT3ZVS3ACF8AEV2WG", + "totp_step": null + }], + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 4 + } +}` + +func TestGetUserTokensMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getUserTokensPage1Response) + } else { + fmt.Fprintln(w, getUserTokensPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserTokens("DU3RP9I2WOC59VZX672N") + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 4 { + t.Errorf("Expected four tokens in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetUserTokensPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetUserTokens("DU3RP9I2WOC59VZX672N", func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const associateUserTokenResponse = `{ + "stat": "OK", + "response": "" +}` + +func TestAssociateUserToken(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, associateUserTokenResponse) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.AssociateUserToken("DU3RP9I2WOC59VZX672N", "DHEKH0JJIYC1LX3AZWO4") + if err != nil { + t.Errorf("Unexpected error from AssociateUserToken call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 0 { + t.Errorf("Expected empty response, but got %s", result.Response) + } +} + +const getUserU2FTokensResponse = `{ + "stat": "OK", + "response": [{ + "date_added": 1444678994, + "registration_id": "D21RU6X1B1DF5P54B6PV" + }], + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 1 + } +}` + +func TestGetUserU2FTokens(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getUserU2FTokensResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserU2FTokens("DU3RP9I2WOC59VZX672N") + if err != nil { + t.Errorf("Unexpected error from GetUserU2FTokens call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 token, but got %d", len(result.Response)) + } + if result.Response[0].RegistrationID != "D21RU6X1B1DF5P54B6PV" { + t.Errorf("Expected registration ID D21RU6X1B1DF5P54B6PV, but got %s", result.Response[0].RegistrationID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getUserU2FTokensPage1Response = `{ + "stat": "OK", + "response": [{ + "date_added": 1444678994, + "registration_id": "D21RU6X1B1DF5P54B6PV" + }], + "metadata": { + "prev_offset": null, + "next_offset": 1, + "total_objects": 2 + } +}` + +const getUserU2FTokensPage2Response = `{ + "stat": "OK", + "response": [{ + "date_added": 1444678994, + "registration_id": "D21RU6X1B1DF5P54B6PV" + }], + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetUserU2FTokensMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getUserU2FTokensPage1Response) + } else { + fmt.Fprintln(w, getUserU2FTokensPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetUserU2FTokens("DU3RP9I2WOC59VZX672N") + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 2 { + t.Errorf("Expected two tokens in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetUserU2FTokensPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetUserU2FTokens("DU3RP9I2WOC59VZX672N", func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +func TestGetGroups(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getGroupsResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetGroups() + if err != nil { + t.Errorf("Unexpected error from GetGroups call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 2 { + t.Errorf("Expected 2 groups, but got %d", len(result.Response)) + } + if result.Response[0].Name != "Group A" { + t.Errorf("Expected group name Group A, but got %s", result.Response[0].Name) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +func TestGetGroupsMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getGroupsPage1Response) + } else { + fmt.Fprintln(w, getGroupsPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetGroups() + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 4 { + t.Errorf("Expected four groups in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetGroupsPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetGroups(func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getGroupResponse = `{ + "response": { + "desc": "Group description", + "group_id": "DGXXXXXXXXXXXXXXXXXX", + "name": "Group Name", + "push_enabled": true, + "sms_enabled": true, + "status": "active", + "voice_enabled": true, + "mobile_otp_enabled": true + }, + "stat": "OK" +}` + +func TestGetGroup(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getGroupResponse) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetGroup("DGXXXXXXXXXXXXXXXXXX") + if err != nil { + t.Errorf("Unexpected error from GetGroups call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if result.Response.GroupID != "DGXXXXXXXXXXXXXXXXXX" { + t.Errorf("Expected group ID DGXXXXXXXXXXXXXXXXXX, but got %s", result.Response.GroupID) + } + if !result.Response.PushEnabled { + t.Errorf("Expected push to be enabled, but got %v", result.Response.PushEnabled) + } +} + +const getPhonesResponse = `{ + "stat": "OK", + "response": [{ + "activated": true, + "capabilities": [ + "push", + "sms", + "phone", + "mobile_otp" + ], + "encrypted": "Encrypted", + "extension": "", + "fingerprint": "Configured", + "name": "", + "number": "+15555550100", + "phone_id": "DPFZRS9FB0D46QFTM899", + "platform": "Google Android", + "postdelay": "", + "predelay": "", + "screenlock": "Locked", + "sms_passcodes_sent": false, + "tampered": "Not tampered", + "type": "Mobile", + "users": [{ + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "email": "jsmith@example.com", + "firstname": "Joe", + "last_login": 1474399627, + "lastname": "Smith", + "notes": "", + "realname": "Joe Smith", + "status": "active", + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith" + }] + }], + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 1 + } +}` + +func TestGetPhones(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getPhonesResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetPhones() + if err != nil { + t.Errorf("Unexpected error from GetPhones call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 phone, but got %d", len(result.Response)) + } + if result.Response[0].PhoneID != "DPFZRS9FB0D46QFTM899" { + t.Errorf("Expected phone ID DPFZRS9FB0D46QFTM899, but got %s", result.Response[0].PhoneID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getPhonesPage1Response = `{ + "stat": "OK", + "response": [{ + "activated": true, + "capabilities": [ + "push", + "sms", + "phone", + "mobile_otp" + ], + "encrypted": "Encrypted", + "extension": "", + "fingerprint": "Configured", + "name": "", + "number": "+15555550100", + "phone_id": "DPFZRS9FB0D46QFTM899", + "platform": "Google Android", + "postdelay": "", + "predelay": "", + "screenlock": "Locked", + "sms_passcodes_sent": false, + "tampered": "Not tampered", + "type": "Mobile", + "users": [{ + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "email": "jsmith@example.com", + "firstname": "Joe", + "last_login": 1474399627, + "lastname": "Smith", + "notes": "", + "realname": "Joe Smith", + "status": "active", + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith" + }] + }], + "metadata": { + "prev_offset": null, + "next_offset": 1, + "total_objects": 2 + } +}` + +const getPhonesPage2Response = `{ + "stat": "OK", + "response": [{ + "activated": true, + "capabilities": [ + "push", + "sms", + "phone", + "mobile_otp" + ], + "encrypted": "Encrypted", + "extension": "", + "fingerprint": "Configured", + "name": "", + "number": "+15555550100", + "phone_id": "DPFZRS9FB0D46QFTM899", + "platform": "Google Android", + "postdelay": "", + "predelay": "", + "screenlock": "Locked", + "sms_passcodes_sent": false, + "tampered": "Not tampered", + "type": "Mobile", + "users": [{ + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "email": "jsmith@example.com", + "firstname": "Joe", + "last_login": 1474399627, + "lastname": "Smith", + "notes": "", + "realname": "Joe Smith", + "status": "active", + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith" + }] + }], + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetPhonesMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getPhonesPage1Response) + } else { + fmt.Fprintln(w, getPhonesPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetPhones() + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 2 { + t.Errorf("Expected two phones in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetPhonesPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetPhones(func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getPhoneResponse = `{ + "stat": "OK", + "response": { + "phone_id": "DPFZRS9FB0D46QFTM899", + "number": "+15555550100", + "name": "", + "extension": "", + "postdelay": null, + "predelay": null, + "type": "Mobile", + "capabilities": [ + "sms", + "phone", + "push" + ], + "platform": "Apple iOS", + "activated": false, + "sms_passcodes_sent": false, + "users": [{ + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith", + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "realname": "Joe Smith", + "email": "jsmith@example.com", + "status": "active", + "last_login": 1343921403, + "notes": "" + }] + } +}` + +func TestGetPhone(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getPhoneResponse) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetPhone("DPFZRS9FB0D46QFTM899") + if err != nil { + t.Errorf("Unexpected error from GetPhone call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if result.Response.PhoneID != "DPFZRS9FB0D46QFTM899" { + t.Errorf("Expected phone ID DPFZRS9FB0D46QFTM899, but got %s", result.Response.PhoneID) + } +} + +const getTokensResponse = `{ + "stat": "OK", + "response": [{ + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1", + "totp_step": null, + "users": [{ + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith", + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "realname": "Joe Smith", + "email": "jsmith@example.com", + "status": "active", + "last_login": 1343921403, + "notes": "" + }] + }], + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 1 + } +}` + +func TestGetTokens(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getTokensResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetTokens() + if err != nil { + t.Errorf("Unexpected error from GetTokens call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 token, but got %d", len(result.Response)) + } + if result.Response[0].TokenID != "DHIZ34ALBA2445ND4AI2" { + t.Errorf("Expected token ID DHIZ34ALBA2445ND4AI2, but got %s", result.Response[0].TokenID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getTokensPage1Response = `{ + "stat": "OK", + "response": [{ + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1", + "totp_step": null, + "users": [{ + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith", + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "realname": "Joe Smith", + "email": "jsmith@example.com", + "status": "active", + "last_login": 1343921403, + "notes": "" + }] + }], + "metadata": { + "prev_offset": null, + "next_offset": 1, + "total_objects": 2 + } +}` + +const getTokensPage2Response = `{ + "stat": "OK", + "response": [{ + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1", + "totp_step": null, + "users": [{ + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith", + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "realname": "Joe Smith", + "email": "jsmith@example.com", + "status": "active", + "last_login": 1343921403, + "notes": "" + }] + }], + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetTokensMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getTokensPage1Response) + } else { + fmt.Fprintln(w, getTokensPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetTokens() + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 2 { + t.Errorf("Expected two tokens in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetTokensPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetTokens(func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getTokenResponse = `{ + "stat": "OK", + "response": { + "serial": "0", + "token_id": "DHIZ34ALBA2445ND4AI2", + "type": "d1", + "totp_step": null, + "users": [{ + "user_id": "DUJZ2U4L80HT45MQ4EOQ", + "username": "jsmith", + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "realname": "Joe Smith", + "email": "jsmith@example.com", + "status": "active", + "last_login": 1343921403, + "notes": "" + }] + } +}` + +func TestGetToken(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getTokenResponse) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetToken("DPFZRS9FB0D46QFTM899") + if err != nil { + t.Errorf("Unexpected error from GetToken call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if result.Response.TokenID != "DHIZ34ALBA2445ND4AI2" { + t.Errorf("Expected token ID DHIZ34ALBA2445ND4AI2, but got %s", result.Response.TokenID) + } +} + +const getU2FTokensResponse = `{ + "stat": "OK", + "response": [{ + "date_added": 1444678994, + "registration_id": "D21RU6X1B1DF5P54B6PV", + "user": { + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "created": 1384275337, + "email": "jsmith@example.com", + "firstname": "Joe", + "last_directory_sync": 1384275337, + "last_login": 1514922986, + "lastname": "Smith", + "notes": "", + "realname": "Joe Smith", + "status": "active", + "user_id": "DU3RP9I2WOC59VZX672N", + "username": "jsmith" + } + }], + "metadata": { + "prev_offset": null, + "next_offset": null, + "total_objects": 1 + } +}` + +func TestGetU2FTokens(t *testing.T) { + var last_request *http.Request + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getU2FTokensResponse) + last_request = r + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetU2FTokens() + if err != nil { + t.Errorf("Unexpected error from GetU2FTokens call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 token, but got %d", len(result.Response)) + } + if result.Response[0].RegistrationID != "D21RU6X1B1DF5P54B6PV" { + t.Errorf("Expected registration ID D21RU6X1B1DF5P54B6PV, but got %s", result.Response[0].RegistrationID) + } + + request_query := last_request.URL.Query() + if request_query["limit"][0] != "100" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "0" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +const getU2FTokensPage1Response = `{ + "stat": "OK", + "response": [{ + "date_added": 1444678994, + "registration_id": "D21RU6X1B1DF5P54B6PV", + "user": { + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "created": 1384275337, + "email": "jsmith@example.com", + "firstname": "Joe", + "last_directory_sync": 1384275337, + "last_login": 1514922986, + "lastname": "Smith", + "notes": "", + "realname": "Joe Smith", + "status": "active", + "user_id": "DU3RP9I2WOC59VZX672N", + "username": "jsmith" + } + }], + "metadata": { + "prev_offset": null, + "next_offset": 1, + "total_objects": 2 + } +}` + +const getU2FTokensPage2Response = `{ + "stat": "OK", + "response": [{ + "date_added": 1444678994, + "registration_id": "D21RU6X1B1DF5P54B6PV", + "user": { + "alias1": "joe.smith", + "alias2": "jsmith@example.com", + "alias3": null, + "alias4": null, + "created": 1384275337, + "email": "jsmith@example.com", + "firstname": "Joe", + "last_directory_sync": 1384275337, + "last_login": 1514922986, + "lastname": "Smith", + "notes": "", + "realname": "Joe Smith", + "status": "active", + "user_id": "DU3RP9I2WOC59VZX672N", + "username": "jsmith" + } + }], + "metadata": { + "prev_offset": 0, + "next_offset": null, + "total_objects": 2 + } +}` + +func TestGetU2fTokensMultiple(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(requests) == 0 { + fmt.Fprintln(w, getU2FTokensPage1Response) + } else { + fmt.Fprintln(w, getU2FTokensPage2Response) + } + requests = append(requests, r) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetU2FTokens() + + if len(requests) != 2 { + t.Errorf("Expected two requets, found %d", len(requests)) + } + + if len(result.Response) != 2 { + t.Errorf("Expected two tokens in the response, found %d", len(result.Response)) + } + + if err != nil { + t.Errorf("Expected err to be nil, found %s", err) + } +} + +func TestGetU2FTokensPageArgs(t *testing.T) { + requests := []*http.Request{} + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getEmptyPageArgsResponse) + requests = append(requests, r) + }), + ) + + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + _, err := duo.GetU2FTokens(func(values *url.Values){ + values.Set("limit", "200") + values.Set("offset", "1") + return + }) + + if err != nil { + t.Errorf("Encountered unexpected error: %s", err) + } + + if len(requests) != 1 { + t.Errorf("Expected there to be one request, found %d", len(requests)) + } + request := requests[0] + request_query := request.URL.Query() + if request_query["limit"][0] != "200" { + t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) + } + if request_query["offset"][0] != "1" { + t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) + } +} + +func TestGetU2FToken(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, getU2FTokensResponse) + }), + ) + defer ts.Close() + + duo := buildAdminClient(ts.URL, nil) + + result, err := duo.GetU2FToken("D21RU6X1B1DF5P54B6PV") + if err != nil { + t.Errorf("Unexpected error from GetU2FToken call %v", err.Error()) + } + if result.Stat != "OK" { + t.Errorf("Expected OK, but got %s", result.Stat) + } + if len(result.Response) != 1 { + t.Errorf("Expected 1 token, but got %d", len(result.Response)) + } + if result.Response[0].RegistrationID != "D21RU6X1B1DF5P54B6PV" { + t.Errorf("Expected registration ID D21RU6X1B1DF5P54B6PV, but got %s", result.Response[0].RegistrationID) + } +} diff --git a/vendor/github.com/duosecurity/duo_api_golang/authapi/authapi.go b/vendor/github.com/duosecurity/duo_api_golang/authapi/authapi.go new file mode 100644 index 0000000..c91eca6 --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/authapi/authapi.go @@ -0,0 +1,381 @@ +package authapi + +import ( + "encoding/json" + "net/url" + "strconv" + + "github.com/duosecurity/duo_api_golang" +) + +type AuthApi struct { + 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} +} + +// Leaving for backwards compatibility. +// The struct in use has been moved to the duoapi package, to be shared between the admin and authapi packages. +type StatResult struct { + Stat string + Code *int32 + Message *string + Message_Detail *string +} + +// Return object for the 'Ping' API call. +type PingResult struct { + duoapi.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.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 { + duoapi.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.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 { + duoapi.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.SignedCall("GET", "/auth/v2/logo", nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + if resp.StatusCode == 200 { + ret := &LogoResult{StatResult: duoapi.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 { + duoapi.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.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 { + duoapi.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.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 { + duoapi.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.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 { + duoapi.StatResult + Response struct { + // Synchronous + Result string + Status string + Status_Msg string + Trusted_Device_Token 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.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 { + duoapi.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.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/vendor/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go b/vendor/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go new file mode 100644 index 0000000..f373756 --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go @@ -0,0 +1,604 @@ +package authapi + +import ( + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/duosecurity/duo_api_golang" +) + +func buildAuthApi(url string, proxy func(*http.Request) (*url.URL, error)) *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(), + duoapi.SetProxy(proxy))) +} + +func getBodyParams(r *http.Request) (url.Values, error) { + body, err := ioutil.ReadAll(r.Body) + r.Body.Close() + if err != nil { + return url.Values{}, err + } + req_params, err := url.ParseQuery(string(body)) + return req_params, err +} + +// 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, nil) + + start := time.Now() + _, err := duo.Ping() + duration := time.Since(start) + if duration.Seconds() > 2 { + t.Errorf("Timeout took %v 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, nil) + + 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, nil) + + 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 Check request / response through a proxy +func TestProxy(t *testing.T) { + // Proxy server + ps := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.Method == "CONNECT" { + // Proxy the connection through to the requested host. + conn, err := net.Dial("tcp", r.URL.Host) + if err != nil { + t.Error("Failed to connect to " + r.URL.String() + ", " + err.Error()) + return + } + // Take over the request connection. + hj, _ := w.(http.Hijacker) + reqconn, _, err := hj.Hijack() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Tell the client that everything is going to be OK. + reqconn.Write([]byte("HTTP/1.0 200 OK\r\n\r\n")) + // Copy all the things. + f := func(src, dst net.Conn) { + defer src.Close() + io.Copy(src, dst) + } + go f(conn, reqconn) + go f(reqconn, conn) + } else { + t.Error("Expected CONNECT, but got " + r.Method) + } + })) + defer ps.Close() + + // Duo dummy server response. + ts := httptest.NewTLSServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "time": 1357020061 + } + }`) + })) + defer ts.Close() + + // Connect through the test proxy. + proxy_url, err := url.Parse(ps.URL) + duo := buildAuthApi(ts.URL, http.ProxyURL(proxy_url)) + + result, err := duo.Check() + if err != nil { + t.Fatal("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, nil) + + _, 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, nil) + + 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) { + req_params, err := getBodyParams(r) + if err != nil { + t.Error("Failed to retrieve body parameters") + } + if req_params.Get("username") != "49c6c3097adb386048c84354d82ea63d" { + t.Error("TestEnroll failed to set 'username' query parameter:" + + r.RequestURI) + } + if req_params.Get("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, nil) + + 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) { + req_params, err := getBodyParams(r) + if err != nil { + t.Error("Failed to retrieve body parameters") + } + if req_params.Get("user_id") != "49c6c3097adb386048c84354d82ea63d" { + t.Error("TestEnrollStatus failed to set 'user_id' query parameter:" + + r.RequestURI) + } + if req_params.Get("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, nil) + + 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) { + req_params, err := getBodyParams(r) + if err != nil { + t.Error("Failed to retrieve body parameters") + } + if req_params.Get("ipaddr") != "127.0.0.1" { + t.Error("TestPreauth failed to set 'ipaddr' query parameter:" + + r.RequestURI) + } + if req_params.Get("user_id") != "10" { + t.Error("TestEnrollStatus failed to set 'user_id' query parameter: " + + r.RequestURI) + } + if req_params.Get("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, nil) + + 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) { + req_params, err := getBodyParams(r) + if err != nil { + t.Error("Failed to retrieve body parameters") + } + if req_params.Get("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, nil) + + 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) { + req_params, err := getBodyParams(r) + if err != nil { + t.Error("Failed to retrieve body parameters") + } + 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 req_params.Get(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, nil) + + 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, nil) + + 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/vendor/github.com/duosecurity/duo_api_golang/duo_test.go b/vendor/github.com/duosecurity/duo_api_golang/duo_test.go new file mode 100644 index 0000000..e3526cb --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/duo_test.go @@ -0,0 +1,156 @@ +package duoapi + +import ( + "net/url" + "strings" + "testing" +) + +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/vendor/github.com/duosecurity/duo_api_golang/duoapi.go b/vendor/github.com/duosecurity/duo_api_golang/duoapi.go new file mode 100644 index 0000000..4c13356 --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/duoapi.go @@ -0,0 +1,387 @@ +package duoapi + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "io/ioutil" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +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 + proxy func(*http.Request) (*url.URL, error) +} + +// 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 + } +} + +// Optional parameter for NewDuoApi, used to configure an HTTP Connect proxy +// server for all outbound communications. +func SetProxy(proxy func(*http.Request) (*url.URL, error)) func(*apiOptions) { + return func(opts *apiOptions) { + opts.proxy = proxy + } +} + +// 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. 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, + skey string, + host string, + userAgent string, + options ...func(*apiOptions)) *DuoApi { + opts := apiOptions{proxy: http.ProxyFromEnvironment} + for _, o := range options { + o(&opts) + } + + // Certificate pinning + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM([]byte(duoPinnedCert)) + + tr := &http.Transport{ + Proxy: opts.proxy, + 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 +} + +// 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 +} + +// 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, + } + method = strings.ToUpper(method) + + if method == "GET" { + url.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) + + if method == "POST" || method == "PUT" { + request.Body = ioutil.NopCloser(strings.NewReader(params.Encode())) + request.Header.Set("Content-type", "application/x-www-form-urlencoded") + } + + 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 +-----BEGIN CERTIFICATE----- +MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv +b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl +cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c +JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP +mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+ +wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4 +VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/ +AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB +AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun +pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC +dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf +fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm +NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx +H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe ++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== +-----END CERTIFICATE----- + +subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Global Root CA +-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD +QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB +CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 +nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P +T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 +gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR +TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw +DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr +hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg +06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF +PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE----- + +subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j +ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL +MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 +LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug +RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm ++9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW +PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM +xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB +Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 +hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg +EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA +FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec +nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z +eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF +hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 +Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe +vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep ++OkuE6N36B9K +-----END CERTIFICATE----- + +subject= /C=US/O=SecureTrust Corporation/CN=SecureTrust CA +-----BEGIN CERTIFICATE----- +MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI +MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x +FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz +MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv +cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz +Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO +0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao +wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj +7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS +8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT +BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg +JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3 +6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/ +3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm +D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS +CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR +3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= +-----END CERTIFICATE----- + +subject= /C=US/O=SecureTrust Corporation/CN=Secure Global CA +-----BEGIN CERTIFICATE----- +MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK +MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x +GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx +MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg +Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ +iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa +/FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ +jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI +HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7 +sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w +gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw +KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG +AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L +URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO +H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm +I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY +iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc +f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW +-----END CERTIFICATE-----` diff --git a/vendor/github.com/duosecurity/duo_api_golang/go.mod b/vendor/github.com/duosecurity/duo_api_golang/go.mod new file mode 100644 index 0000000..fc66cec --- /dev/null +++ b/vendor/github.com/duosecurity/duo_api_golang/go.mod @@ -0,0 +1 @@ +module github.com/duosecurity/duo_api_golang