├── .github └── workflows │ └── golang-ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── admin ├── admin.go ├── admin_test.go ├── logs.go └── logs_test.go ├── authapi ├── authapi.go └── authapi_test.go ├── duo_test.go ├── duoapi.go ├── go.mod └── go.sum /.github/workflows/golang-ci.yml: -------------------------------------------------------------------------------- 1 | name: Golang CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build-and-test: 12 | name: Golang CI - test 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | go: [1.14.x, 1.15.x] 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Golang 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: ${{ matrix.go }} 24 | - name: Lint 25 | run: diff -u <(echo -n) <(gofmt -d .) 26 | - name: Build 27 | run: go build ./... 28 | - name: Test 29 | run: go test -v -race ./... 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 3. The name of the author may not be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 17 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 25 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | [![Build Status](https://github.com/duosecurity/duo_api_golang/workflows/Golang%20CI/badge.svg)](https://github.com/duosecurity/duo_api_golang/actions) 4 | [![Issues](https://img.shields.io/github/issues/duosecurity/duo_api_golang)](https://github.com/duosecurity/duo_api_golang/issues) 5 | [![Forks](https://img.shields.io/github/forks/duosecurity/duo_api_golang)](https://github.com/duosecurity/duo_api_golang/network/members) 6 | [![Stars](https://img.shields.io/github/stars/duosecurity/duo_api_golang)](https://github.com/duosecurity/duo_api_golang/stargazers) 7 | [![License](https://img.shields.io/badge/License-View%20License-orange)](https://github.com/duosecurity/duo_api_golang/blob/master/LICENSE) 8 | 9 | **duo_api_golang** - Go language bindings for the Duo APIs (both auth and admin). 10 | 11 | ## TLS 1.2 and 1.3 Support 12 | 13 | Duo_api_golang uses the Go cryptography library for TLS operations. Go versions 1.13 and higher support both TLS 1.2 and 1.3. 14 | 15 | ## Duo Auth API 16 | 17 | The Auth API is a low-level, RESTful API for adding strong two-factor authentication to your website or application. 18 | 19 | This module's API client implementation is *complete*; corresponding methods are exported for all available endpoints. 20 | 21 | For more information see the [Auth API guide](https://duo.com/docs/authapi). 22 | 23 | ## Duo Admin API 24 | 25 | The Admin API provides programmatic access to the administrative functionality of Duo Security's two-factor authentication platform. 26 | 27 | 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! 28 | 29 | For more information see the [Admin API guide](https://duo.com/docs/adminapi). 30 | 31 | ## Testing 32 | 33 | ``` 34 | $ go test -v -race ./... 35 | ``` 36 | 37 | ## Linting 38 | 39 | ``` 40 | $ gofmt -d . 41 | ``` 42 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Duo is committed to providing secure software to all our customers and users. We take all security concerns seriously and ask that any disclosures be handled responsibly. 2 | 3 | # Security Policy 4 | 5 | ## Reporting a Vulnerability 6 | **Please do not use Github issues or pull requests to report security vulnerabilities.** 7 | 8 | If you believe you have found a security vulnerability in Duo software, please follow our response process described at https://duo.com/support/security-and-reliability/security-response. 9 | -------------------------------------------------------------------------------- /admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "reflect" 9 | "strconv" 10 | 11 | duoapi "github.com/duosecurity/duo_api_golang" 12 | ) 13 | 14 | // Client provides access to Duo's admin API. 15 | type Client struct { 16 | duoapi.DuoApi 17 | } 18 | 19 | type ListResultMetadata struct { 20 | NextOffset json.Number `json:"next_offset"` 21 | PrevOffset json.Number `json:"prev_offset"` 22 | TotalObjects json.Number `json:"total_objects"` 23 | } 24 | 25 | type ListResult struct { 26 | Metadata ListResultMetadata `json:"metadata"` 27 | } 28 | 29 | func (l *ListResult) metadata() ListResultMetadata { 30 | return l.Metadata 31 | } 32 | 33 | // New initializes an admin API Client struct. 34 | func New(base duoapi.DuoApi) *Client { 35 | return &Client{base} 36 | } 37 | 38 | // User models a single user. 39 | type User struct { 40 | Alias1 *string `json:"alias1" url:"alias1"` 41 | Alias2 *string `json:"alias2" url:"alias2"` 42 | Alias3 *string `json:"alias3" url:"alias3"` 43 | Alias4 *string `json:"alias4" url:"alias4"` 44 | Created uint64 `json:"created"` 45 | Email string `json:"email" url:"email"` 46 | FirstName *string `json:"firstname" url:"firstname"` 47 | Groups []Group `json:"groups"` 48 | IsEnrolled bool `json:"is_enrolled"` 49 | LastDirectorySync *uint64 `json:"last_directory_sync"` 50 | LastLogin *uint64 `json:"last_login"` 51 | LastName *string `json:"lastname" url:"lastname"` 52 | LockoutReason *string `json:"lockout_reason"` 53 | Notes string `json:"notes" url:"notes"` 54 | Phones []Phone `json:"phones"` 55 | RealName *string `json:"realname" url:"realname"` 56 | Status string `json:"status" url:"status"` 57 | Tokens []Token `json:"tokens"` 58 | U2FTokens []U2FToken `json:"u2ftokens"` 59 | UserID string `json:"user_id"` 60 | Username string `json:"username" url:"username"` 61 | WebAuthnTokens []WebAuthnToken `json:"webauthncredentials"` 62 | } 63 | 64 | // URLValues transforms a User into url.Values using the 'url' struct tag to 65 | // define the key of the map. Fields are skiped if the value is empty. 66 | func (u *User) URLValues() url.Values { 67 | params := url.Values{} 68 | 69 | t := reflect.TypeOf(u).Elem() 70 | v := reflect.ValueOf(u).Elem() 71 | 72 | // Iterate over all available struct fields 73 | for i := 0; i < t.NumField(); i++ { 74 | tag := t.Field(i).Tag.Get("url") 75 | if tag == "" { 76 | continue 77 | } 78 | // Skip fields have a zero value. 79 | if v.Field(i).Interface() == reflect.Zero(v.Field(i).Type()).Interface() { 80 | continue 81 | } 82 | var val string 83 | if t.Field(i).Type.Kind() == reflect.Ptr { 84 | val = fmt.Sprintf("%v", v.Field(i).Elem()) 85 | } else { 86 | val = fmt.Sprintf("%v", v.Field(i)) 87 | } 88 | params[tag] = []string{val} 89 | } 90 | return params 91 | } 92 | 93 | // Group models a group to which users may belong. 94 | type Group struct { 95 | Desc string 96 | GroupID string `json:"group_id"` 97 | MobileOTPEnabled bool `json:"mobile_otp_enabled"` 98 | Name string 99 | PushEnabled bool `json:"push_enabled"` 100 | SMSEnabled bool `json:"sms_enabled"` 101 | Status string 102 | VoiceEnabled bool `json:"voice_enabled"` 103 | } 104 | 105 | // Phone models a user's phone. 106 | type Phone struct { 107 | Activated bool 108 | Capabilities []string 109 | Encrypted string 110 | Extension string 111 | Fingerprint string 112 | LastSeen string `json:"last_seen"` 113 | Model string 114 | Name string 115 | Number string 116 | PhoneID string `json:"phone_id"` 117 | Platform string 118 | Postdelay string 119 | Predelay string 120 | Screenlock string 121 | SMSPasscodesSent bool 122 | Type string 123 | Users []User 124 | } 125 | 126 | // Token models a hardware security token. 127 | type Token struct { 128 | TokenID string `json:"token_id"` 129 | Type string 130 | Serial string 131 | TOTPStep *int `json:"totp_step"` 132 | Users []User 133 | } 134 | 135 | type WebAuthnToken struct { 136 | CredentialName string `json:"credential_name"` 137 | DateAdded uint64 `json:"date_added"` 138 | Label string `json:"label"` 139 | WebAuthnKey string `json:"webauthnkey"` 140 | } 141 | 142 | // U2FToken models a U2F security token. 143 | type U2FToken struct { 144 | DateAdded uint64 `json:"date_added"` 145 | RegistrationID string `json:"registration_id"` 146 | User *User 147 | } 148 | 149 | // Common URL options 150 | 151 | // Limit sets the optional limit parameter for an API request. 152 | func Limit(limit uint64) func(*url.Values) { 153 | return func(opts *url.Values) { 154 | opts.Set("limit", strconv.FormatUint(limit, 10)) 155 | } 156 | } 157 | 158 | // Offset sets the optional offset parameter for an API request. 159 | func Offset(offset uint64) func(*url.Values) { 160 | return func(opts *url.Values) { 161 | opts.Set("offset", strconv.FormatUint(offset, 10)) 162 | } 163 | } 164 | 165 | // User methods 166 | 167 | // GetUsersUsername sets the optional username parameter for a GetUsers request. 168 | func GetUsersUsername(name string) func(*url.Values) { 169 | return func(opts *url.Values) { 170 | opts.Set("username", name) 171 | } 172 | } 173 | 174 | // GetUsersResult models responses containing a list of users. 175 | type GetUsersResult struct { 176 | duoapi.StatResult 177 | ListResult 178 | Response []User 179 | } 180 | 181 | // GetUserResult models responses containing a single user. 182 | type GetUserResult struct { 183 | duoapi.StatResult 184 | Response User 185 | } 186 | 187 | func (result *GetUsersResult) getResponse() interface{} { 188 | return result.Response 189 | } 190 | 191 | func (result *GetUsersResult) appendResponse(users interface{}) { 192 | asserted_users := users.([]User) 193 | result.Response = append(result.Response, asserted_users...) 194 | } 195 | 196 | // GetUsers calls GET /admin/v1/users 197 | // See https://duo.com/docs/adminapi#retrieve-users 198 | func (c *Client) GetUsers(options ...func(*url.Values)) (*GetUsersResult, error) { 199 | params := url.Values{} 200 | for _, o := range options { 201 | o(¶ms) 202 | } 203 | 204 | cb := func(params url.Values) (responsePage, error) { 205 | return c.retrieveUsers(params) 206 | } 207 | response, err := c.retrieveItems(params, cb) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | return response.(*GetUsersResult), nil 213 | } 214 | 215 | type responsePage interface { 216 | metadata() ListResultMetadata 217 | getResponse() interface{} 218 | appendResponse(interface{}) 219 | } 220 | 221 | type pageFetcher func(params url.Values) (responsePage, error) 222 | 223 | func (c *Client) retrieveItems( 224 | params url.Values, 225 | fetcher pageFetcher, 226 | ) (responsePage, error) { 227 | if params.Get("offset") == "" { 228 | params.Set("offset", "0") 229 | } 230 | 231 | if params.Get("limit") == "" { 232 | params.Set("limit", "100") 233 | accumulator, firstErr := fetcher(params) 234 | 235 | if firstErr != nil { 236 | return nil, firstErr 237 | } 238 | 239 | params.Set("offset", accumulator.metadata().NextOffset.String()) 240 | for params.Get("offset") != "" { 241 | nextResult, err := fetcher(params) 242 | if err != nil { 243 | return nil, err 244 | } 245 | nextResult.appendResponse(accumulator.getResponse()) 246 | accumulator = nextResult 247 | params.Set("offset", accumulator.metadata().NextOffset.String()) 248 | } 249 | return accumulator, nil 250 | } 251 | 252 | return fetcher(params) 253 | } 254 | 255 | func (c *Client) retrieveUsers(params url.Values) (*GetUsersResult, error) { 256 | _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/users", params, duoapi.UseTimeout) 257 | if err != nil { 258 | return nil, err 259 | } 260 | 261 | result := &GetUsersResult{} 262 | err = json.Unmarshal(body, result) 263 | if err != nil { 264 | return nil, err 265 | } 266 | return result, nil 267 | } 268 | 269 | // GetUser calls GET /admin/v1/users/:user_id 270 | // See https://duo.com/docs/adminapi#retrieve-user-by-id 271 | func (c *Client) GetUser(userID string) (*GetUserResult, error) { 272 | path := fmt.Sprintf("/admin/v1/users/%s", userID) 273 | 274 | _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) 275 | if err != nil { 276 | return nil, err 277 | } 278 | 279 | result := &GetUserResult{} 280 | err = json.Unmarshal(body, result) 281 | if err != nil { 282 | return nil, err 283 | } 284 | return result, nil 285 | } 286 | 287 | // CreateUser calls POST /admin/v1/users 288 | // See https://duo.com/docs/adminapi#create-user 289 | func (c *Client) CreateUser(params url.Values) (*GetUserResult, error) { 290 | path := "/admin/v1/users" 291 | 292 | _, body, err := c.SignedCall(http.MethodPost, path, params, duoapi.UseTimeout) 293 | if err != nil { 294 | return nil, err 295 | } 296 | 297 | result := &GetUserResult{} 298 | err = json.Unmarshal(body, result) 299 | if err != nil { 300 | return nil, err 301 | } 302 | return result, nil 303 | } 304 | 305 | // ModifyUser calls POST /admin/v1/users/:user_id 306 | // See https://duo.com/docs/adminapi#modify-user 307 | func (c *Client) ModifyUser(userID string, params url.Values) (*GetUserResult, error) { 308 | path := fmt.Sprintf("/admin/v1/users/%s", userID) 309 | 310 | _, body, err := c.SignedCall(http.MethodPost, path, params, duoapi.UseTimeout) 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | result := &GetUserResult{} 316 | err = json.Unmarshal(body, result) 317 | if err != nil { 318 | return nil, err 319 | } 320 | return result, nil 321 | } 322 | 323 | // DeleteUser calls DELETE /admin/v1/users/:user_id 324 | // See https://duo.com/docs/adminapi#delete-user 325 | func (c *Client) DeleteUser(userID string) (*duoapi.StatResult, error) { 326 | path := fmt.Sprintf("/admin/v1/users/%s", userID) 327 | 328 | _, body, err := c.SignedCall(http.MethodDelete, path, nil, duoapi.UseTimeout) 329 | if err != nil { 330 | return nil, err 331 | } 332 | 333 | result := &duoapi.StatResult{} 334 | err = json.Unmarshal(body, result) 335 | if err != nil { 336 | return nil, err 337 | } 338 | return result, nil 339 | } 340 | 341 | // GetUserGroups calls GET /admin/v1/users/:user_id/groups 342 | // See https://duo.com/docs/adminapi#retrieve-groups-by-user-id 343 | func (c *Client) GetUserGroups(userID string, options ...func(*url.Values)) (*GetGroupsResult, error) { 344 | params := url.Values{} 345 | for _, o := range options { 346 | o(¶ms) 347 | } 348 | 349 | cb := func(params url.Values) (responsePage, error) { 350 | return c.retrieveUserGroups(userID, params) 351 | } 352 | response, err := c.retrieveItems(params, cb) 353 | if err != nil { 354 | return nil, err 355 | } 356 | 357 | return response.(*GetGroupsResult), nil 358 | } 359 | 360 | // AssociateGroupWithUser calls POST /admin/v1/users/:user_id/groups 361 | // See https://duo.com/docs/adminapi#associate-group-with-user 362 | func (c *Client) AssociateGroupWithUser(userID string, groupID string) (*duoapi.StatResult, error) { 363 | path := fmt.Sprintf("/admin/v1/users/%s/groups", userID) 364 | 365 | params := url.Values{} 366 | params.Set("group_id", groupID) 367 | 368 | _, body, err := c.SignedCall(http.MethodPost, path, params, duoapi.UseTimeout) 369 | if err != nil { 370 | return nil, err 371 | } 372 | 373 | result := &duoapi.StatResult{} 374 | err = json.Unmarshal(body, result) 375 | if err != nil { 376 | return nil, err 377 | } 378 | return result, nil 379 | } 380 | 381 | // DisassociateGroupFromUser calls POST /admin/v1/users/:user_id/groups 382 | // See https://duo.com/docs/adminapi#disassociate-group-from-user 383 | func (c *Client) DisassociateGroupFromUser(userID string, groupID string) (*duoapi.StatResult, error) { 384 | path := fmt.Sprintf("/admin/v1/users/%s/groups/%s", userID, groupID) 385 | 386 | _, body, err := c.SignedCall(http.MethodDelete, path, nil, duoapi.UseTimeout) 387 | if err != nil { 388 | return nil, err 389 | } 390 | 391 | result := &duoapi.StatResult{} 392 | err = json.Unmarshal(body, result) 393 | if err != nil { 394 | return nil, err 395 | } 396 | return result, nil 397 | } 398 | 399 | func (c *Client) retrieveUserGroups(userID string, params url.Values) (*GetGroupsResult, error) { 400 | path := fmt.Sprintf("/admin/v1/users/%s/groups", userID) 401 | 402 | _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) 403 | if err != nil { 404 | return nil, err 405 | } 406 | 407 | result := &GetGroupsResult{} 408 | err = json.Unmarshal(body, result) 409 | if err != nil { 410 | return nil, err 411 | } 412 | return result, nil 413 | } 414 | 415 | // GetUserPhones calls GET /admin/v1/users/:user_id/phones 416 | // See https://duo.com/docs/adminapi#retrieve-phones-by-user-id 417 | func (c *Client) GetUserPhones(userID string, options ...func(*url.Values)) (*GetPhonesResult, error) { 418 | params := url.Values{} 419 | for _, o := range options { 420 | o(¶ms) 421 | } 422 | 423 | cb := func(params url.Values) (responsePage, error) { 424 | return c.retrieveUserPhones(userID, params) 425 | } 426 | response, err := c.retrieveItems(params, cb) 427 | if err != nil { 428 | return nil, err 429 | } 430 | 431 | return response.(*GetPhonesResult), nil 432 | } 433 | 434 | func (c *Client) retrieveUserPhones(userID string, params url.Values) (*GetPhonesResult, error) { 435 | path := fmt.Sprintf("/admin/v1/users/%s/phones", userID) 436 | 437 | _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) 438 | if err != nil { 439 | return nil, err 440 | } 441 | 442 | result := &GetPhonesResult{} 443 | err = json.Unmarshal(body, result) 444 | if err != nil { 445 | return nil, err 446 | } 447 | return result, nil 448 | } 449 | 450 | // GetUserTokens calls GET /admin/v1/users/:user_id/tokens 451 | // See https://duo.com/docs/adminapi#retrieve-hardware-tokens-by-user-id 452 | func (c *Client) GetUserTokens(userID string, options ...func(*url.Values)) (*GetTokensResult, error) { 453 | params := url.Values{} 454 | for _, o := range options { 455 | o(¶ms) 456 | } 457 | 458 | cb := func(params url.Values) (responsePage, error) { 459 | return c.retrieveUserTokens(userID, params) 460 | } 461 | response, err := c.retrieveItems(params, cb) 462 | if err != nil { 463 | return nil, err 464 | } 465 | 466 | return response.(*GetTokensResult), nil 467 | } 468 | 469 | func (c *Client) retrieveUserTokens(userID string, params url.Values) (*GetTokensResult, error) { 470 | path := fmt.Sprintf("/admin/v1/users/%s/tokens", userID) 471 | 472 | _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) 473 | if err != nil { 474 | return nil, err 475 | } 476 | 477 | result := &GetTokensResult{} 478 | err = json.Unmarshal(body, result) 479 | if err != nil { 480 | return nil, err 481 | } 482 | return result, nil 483 | } 484 | 485 | // StringResult models responses containing a simple string. 486 | type StringResult struct { 487 | duoapi.StatResult 488 | Response string 489 | } 490 | 491 | // AssociateUserToken calls POST /admin/v1/users/:user_id/tokens 492 | // See https://duo.com/docs/adminapi#associate-hardware-token-with-user 493 | func (c *Client) AssociateUserToken(userID, tokenID string) (*StringResult, error) { 494 | path := fmt.Sprintf("/admin/v1/users/%s/tokens", userID) 495 | 496 | params := url.Values{} 497 | params.Set("token_id", tokenID) 498 | 499 | _, body, err := c.SignedCall(http.MethodPost, path, params, duoapi.UseTimeout) 500 | if err != nil { 501 | return nil, err 502 | } 503 | 504 | result := &StringResult{} 505 | err = json.Unmarshal(body, result) 506 | if err != nil { 507 | return nil, err 508 | } 509 | return result, nil 510 | } 511 | 512 | // GetUserU2FTokens calls GET /admin/v1/users/:user_id/u2ftokens 513 | // See https://duo.com/docs/adminapi#retrieve-u2f-tokens-by-user-id 514 | func (c *Client) GetUserU2FTokens(userID string, options ...func(*url.Values)) (*GetU2FTokensResult, error) { 515 | params := url.Values{} 516 | for _, o := range options { 517 | o(¶ms) 518 | } 519 | 520 | cb := func(params url.Values) (responsePage, error) { 521 | return c.retrieveUserU2FTokens(userID, params) 522 | } 523 | response, err := c.retrieveItems(params, cb) 524 | if err != nil { 525 | return nil, err 526 | } 527 | 528 | return response.(*GetU2FTokensResult), nil 529 | } 530 | 531 | func (c *Client) retrieveUserU2FTokens(userID string, params url.Values) (*GetU2FTokensResult, error) { 532 | path := fmt.Sprintf("/admin/v1/users/%s/u2ftokens", userID) 533 | 534 | _, body, err := c.SignedCall(http.MethodGet, path, params, duoapi.UseTimeout) 535 | if err != nil { 536 | return nil, err 537 | } 538 | 539 | result := &GetU2FTokensResult{} 540 | err = json.Unmarshal(body, result) 541 | if err != nil { 542 | return nil, err 543 | } 544 | return result, nil 545 | } 546 | 547 | // StringArrayResult models response containing an array of strings. 548 | type StringArrayResult struct { 549 | duoapi.StatResult 550 | Response []string 551 | } 552 | 553 | // GetUserBypassCodes calls POST /admin/v1/users/:user_id/bypass_codes 554 | // see https://duo.com/docs/adminapi#create-bypass-codes-for-user 555 | func (c *Client) GetUserBypassCodes(userID string, options ...func(*url.Values)) (*StringArrayResult, error) { 556 | path := fmt.Sprintf("/admin/v1/users/%s/bypass_codes", userID) 557 | 558 | params := url.Values{} 559 | for _, o := range options { 560 | o(¶ms) 561 | } 562 | 563 | _, body, err := c.SignedCall(http.MethodPost, path, params, duoapi.UseTimeout) 564 | if err != nil { 565 | return nil, err 566 | } 567 | 568 | result := &StringArrayResult{} 569 | err = json.Unmarshal(body, result) 570 | if err != nil { 571 | return nil, err 572 | } 573 | 574 | return result, nil 575 | } 576 | 577 | // Group methods 578 | 579 | // GetGroupsResult models responses containing a list of groups. 580 | type GetGroupsResult struct { 581 | duoapi.StatResult 582 | ListResult 583 | Response []Group 584 | } 585 | 586 | func (result *GetGroupsResult) getResponse() interface{} { 587 | return result.Response 588 | } 589 | 590 | func (result *GetGroupsResult) appendResponse(groups interface{}) { 591 | asserted_groups := groups.([]Group) 592 | result.Response = append(result.Response, asserted_groups...) 593 | } 594 | 595 | // GetGroups calls GET /admin/v1/groups 596 | // See https://duo.com/docs/adminapi#retrieve-groups 597 | func (c *Client) GetGroups(options ...func(*url.Values)) (*GetGroupsResult, error) { 598 | params := url.Values{} 599 | for _, o := range options { 600 | o(¶ms) 601 | } 602 | 603 | cb := func(params url.Values) (responsePage, error) { 604 | return c.retrieveGroups(params) 605 | } 606 | response, err := c.retrieveItems(params, cb) 607 | if err != nil { 608 | return nil, err 609 | } 610 | 611 | return response.(*GetGroupsResult), nil 612 | } 613 | 614 | func (c *Client) retrieveGroups(params url.Values) (*GetGroupsResult, error) { 615 | _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/groups", params, duoapi.UseTimeout) 616 | if err != nil { 617 | return nil, err 618 | } 619 | 620 | result := &GetGroupsResult{} 621 | err = json.Unmarshal(body, result) 622 | if err != nil { 623 | return nil, err 624 | } 625 | return result, nil 626 | } 627 | 628 | // GetGroupResult models responses containing a single group. 629 | type GetGroupResult struct { 630 | duoapi.StatResult 631 | Response Group 632 | } 633 | 634 | // GetGroup calls GET /admin/v2/group/:group_id 635 | // See https://duo.com/docs/adminapi#get-group-info 636 | func (c *Client) GetGroup(groupID string) (*GetGroupResult, error) { 637 | path := fmt.Sprintf("/admin/v2/groups/%s", groupID) 638 | 639 | _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) 640 | if err != nil { 641 | return nil, err 642 | } 643 | 644 | result := &GetGroupResult{} 645 | err = json.Unmarshal(body, result) 646 | if err != nil { 647 | return nil, err 648 | } 649 | return result, nil 650 | } 651 | 652 | // Phone methods 653 | 654 | // GetPhonesNumber sets the optional number parameter for a GetPhones request. 655 | func GetPhonesNumber(number string) func(*url.Values) { 656 | return func(opts *url.Values) { 657 | opts.Set("number", number) 658 | } 659 | } 660 | 661 | // GetPhonesExtension sets the optional extension parameter for a GetPhones request. 662 | func GetPhonesExtension(ext string) func(*url.Values) { 663 | return func(opts *url.Values) { 664 | opts.Set("extension", ext) 665 | } 666 | } 667 | 668 | // GetPhonesResult models responses containing a list of phones. 669 | type GetPhonesResult struct { 670 | duoapi.StatResult 671 | ListResult 672 | Response []Phone 673 | } 674 | 675 | func (result *GetPhonesResult) getResponse() interface{} { 676 | return result.Response 677 | } 678 | 679 | func (result *GetPhonesResult) appendResponse(phones interface{}) { 680 | asserted_phones := phones.([]Phone) 681 | result.Response = append(result.Response, asserted_phones...) 682 | } 683 | 684 | // GetPhones calls GET /admin/v1/phones 685 | // See https://duo.com/docs/adminapi#phones 686 | func (c *Client) GetPhones(options ...func(*url.Values)) (*GetPhonesResult, error) { 687 | params := url.Values{} 688 | for _, o := range options { 689 | o(¶ms) 690 | } 691 | 692 | cb := func(params url.Values) (responsePage, error) { 693 | return c.retrievePhones(params) 694 | } 695 | response, err := c.retrieveItems(params, cb) 696 | if err != nil { 697 | return nil, err 698 | } 699 | 700 | return response.(*GetPhonesResult), nil 701 | } 702 | 703 | func (c *Client) retrievePhones(params url.Values) (*GetPhonesResult, error) { 704 | _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/phones", params, duoapi.UseTimeout) 705 | if err != nil { 706 | return nil, err 707 | } 708 | 709 | result := &GetPhonesResult{} 710 | err = json.Unmarshal(body, result) 711 | if err != nil { 712 | return nil, err 713 | } 714 | return result, nil 715 | } 716 | 717 | // GetPhoneResult models responses containing a single phone. 718 | type GetPhoneResult struct { 719 | duoapi.StatResult 720 | Response Phone 721 | } 722 | 723 | // GetPhone calls GET /admin/v1/phones/:phone_id 724 | // See https://duo.com/docs/adminapi#retrieve-phone-by-id 725 | func (c *Client) GetPhone(phoneID string) (*GetPhoneResult, error) { 726 | path := fmt.Sprintf("/admin/v1/phones/%s", phoneID) 727 | 728 | _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) 729 | if err != nil { 730 | return nil, err 731 | } 732 | 733 | result := &GetPhoneResult{} 734 | err = json.Unmarshal(body, result) 735 | if err != nil { 736 | return nil, err 737 | } 738 | return result, nil 739 | } 740 | 741 | // DeletePhone calls DELETE /admin/v1/phones/:phone_id 742 | // See https://duo.com/docs/adminapi#delete-phone 743 | func (c *Client) DeletePhone(phoneID string) (*duoapi.StatResult, error) { 744 | path := fmt.Sprintf("/admin/v1/phones/%s", phoneID) 745 | 746 | _, body, err := c.SignedCall(http.MethodDelete, path, nil, duoapi.UseTimeout) 747 | if err != nil { 748 | return nil, err 749 | } 750 | 751 | result := &duoapi.StatResult{} 752 | err = json.Unmarshal(body, result) 753 | if err != nil { 754 | return nil, err 755 | } 756 | return result, nil 757 | } 758 | 759 | // Token methods 760 | 761 | // GetTokensTypeAndSerial sets the optional type and serial parameters for a GetTokens request. 762 | func GetTokensTypeAndSerial(typ, serial string) func(*url.Values) { 763 | return func(opts *url.Values) { 764 | opts.Set("type", typ) 765 | opts.Set("serial", serial) 766 | } 767 | } 768 | 769 | // GetTokensResult models responses containing a list of tokens. 770 | type GetTokensResult struct { 771 | duoapi.StatResult 772 | ListResult 773 | Response []Token 774 | } 775 | 776 | func (result *GetTokensResult) getResponse() interface{} { 777 | return result.Response 778 | } 779 | 780 | func (result *GetTokensResult) appendResponse(tokens interface{}) { 781 | asserted_tokens := tokens.([]Token) 782 | result.Response = append(result.Response, asserted_tokens...) 783 | } 784 | 785 | // GetTokens calls GET /admin/v1/tokens 786 | // See https://duo.com/docs/adminapi#retrieve-hardware-tokens 787 | func (c *Client) GetTokens(options ...func(*url.Values)) (*GetTokensResult, error) { 788 | params := url.Values{} 789 | for _, o := range options { 790 | o(¶ms) 791 | } 792 | 793 | cb := func(params url.Values) (responsePage, error) { 794 | return c.retrieveTokens(params) 795 | } 796 | response, err := c.retrieveItems(params, cb) 797 | if err != nil { 798 | return nil, err 799 | } 800 | 801 | return response.(*GetTokensResult), nil 802 | } 803 | 804 | func (c *Client) retrieveTokens(params url.Values) (*GetTokensResult, error) { 805 | _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/tokens", params, duoapi.UseTimeout) 806 | if err != nil { 807 | return nil, err 808 | } 809 | 810 | result := &GetTokensResult{} 811 | err = json.Unmarshal(body, result) 812 | if err != nil { 813 | return nil, err 814 | } 815 | return result, nil 816 | } 817 | 818 | // GetTokenResult models responses containing a single token. 819 | type GetTokenResult struct { 820 | duoapi.StatResult 821 | Response Token 822 | } 823 | 824 | // GetToken calls GET /admin/v1/tokens/:token_id 825 | // See https://duo.com/docs/adminapi#retrieve-hardware-tokens 826 | func (c *Client) GetToken(tokenID string) (*GetTokenResult, error) { 827 | path := fmt.Sprintf("/admin/v1/tokens/%s", tokenID) 828 | 829 | _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) 830 | if err != nil { 831 | return nil, err 832 | } 833 | 834 | result := &GetTokenResult{} 835 | err = json.Unmarshal(body, result) 836 | if err != nil { 837 | return nil, err 838 | } 839 | return result, nil 840 | } 841 | 842 | // U2F token methods 843 | 844 | // GetU2FTokensResult models responses containing a list of U2F tokens. 845 | type GetU2FTokensResult struct { 846 | duoapi.StatResult 847 | ListResult 848 | Response []U2FToken 849 | } 850 | 851 | func (result *GetU2FTokensResult) getResponse() interface{} { 852 | return result.Response 853 | } 854 | 855 | func (result *GetU2FTokensResult) appendResponse(tokens interface{}) { 856 | asserted_tokens := tokens.([]U2FToken) 857 | result.Response = append(result.Response, asserted_tokens...) 858 | } 859 | 860 | // GetU2FTokens calls GET /admin/v1/u2ftokens 861 | // See https://duo.com/docs/adminapi#retrieve-u2f-tokens 862 | func (c *Client) GetU2FTokens(options ...func(*url.Values)) (*GetU2FTokensResult, error) { 863 | params := url.Values{} 864 | for _, o := range options { 865 | o(¶ms) 866 | } 867 | 868 | cb := func(params url.Values) (responsePage, error) { 869 | return c.retrieveU2FTokens(params) 870 | } 871 | response, err := c.retrieveItems(params, cb) 872 | if err != nil { 873 | return nil, err 874 | } 875 | 876 | return response.(*GetU2FTokensResult), nil 877 | } 878 | 879 | func (c *Client) retrieveU2FTokens(params url.Values) (*GetU2FTokensResult, error) { 880 | _, body, err := c.SignedCall(http.MethodGet, "/admin/v1/u2ftokens", params, duoapi.UseTimeout) 881 | if err != nil { 882 | return nil, err 883 | } 884 | 885 | result := &GetU2FTokensResult{} 886 | err = json.Unmarshal(body, result) 887 | if err != nil { 888 | return nil, err 889 | } 890 | return result, nil 891 | } 892 | 893 | // GetU2FToken calls GET /admin/v1/u2ftokens/:registration_id 894 | // See https://duo.com/docs/adminapi#retrieve-u2f-token-by-id 895 | func (c *Client) GetU2FToken(registrationID string) (*GetU2FTokensResult, error) { 896 | path := fmt.Sprintf("/admin/v1/u2ftokens/%s", registrationID) 897 | 898 | _, body, err := c.SignedCall(http.MethodGet, path, nil, duoapi.UseTimeout) 899 | if err != nil { 900 | return nil, err 901 | } 902 | 903 | result := &GetU2FTokensResult{} 904 | err = json.Unmarshal(body, result) 905 | if err != nil { 906 | return nil, err 907 | } 908 | return result, nil 909 | } 910 | -------------------------------------------------------------------------------- /admin/admin_test.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | duoapi "github.com/duosecurity/duo_api_golang" 15 | ) 16 | 17 | func buildAdminClient(url string, proxy func(*http.Request) (*url.URL, error)) *Client { 18 | ikey := "eyekey" 19 | skey := "esskey" 20 | host := strings.Split(url, "//")[1] 21 | userAgent := "GoTestClient" 22 | base := duoapi.NewDuoApi(ikey, skey, host, userAgent, duoapi.SetTimeout(1*time.Second), duoapi.SetInsecure(), duoapi.SetProxy(proxy)) 23 | return New(*base) 24 | } 25 | 26 | func getBodyParams(r *http.Request) (url.Values, error) { 27 | body, err := ioutil.ReadAll(r.Body) 28 | r.Body.Close() 29 | if err != nil { 30 | return url.Values{}, err 31 | } 32 | reqParams, err := url.ParseQuery(string(body)) 33 | return reqParams, err 34 | } 35 | 36 | const getUsersResponse = `{ 37 | "stat": "OK", 38 | "metadata": { 39 | "prev_offset": null, 40 | "next_offset": null, 41 | "total_objects": 1 42 | }, 43 | "response": [{ 44 | "alias1": "joe.smith", 45 | "alias2": "jsmith@example.com", 46 | "alias3": null, 47 | "alias4": null, 48 | "created": 1489612729, 49 | "email": "jsmith@example.com", 50 | "firstname": "Joe", 51 | "groups": [{ 52 | "desc": "People with hardware tokens", 53 | "name": "token_users" 54 | }], 55 | "last_directory_sync": 1508789163, 56 | "last_login": 1343921403, 57 | "lastname": "Smith", 58 | "notes": "", 59 | "phones": [{ 60 | "phone_id": "DPFZRS9FB0D46QFTM899", 61 | "number": "+15555550100", 62 | "extension": "", 63 | "name": "", 64 | "postdelay": null, 65 | "predelay": null, 66 | "type": "Mobile", 67 | "capabilities": [ 68 | "sms", 69 | "phone", 70 | "push" 71 | ], 72 | "platform": "Apple iOS", 73 | "model": "Apple iPhone", 74 | "activated": false, 75 | "last_seen": "2019-03-04T15:04:04", 76 | "sms_passcodes_sent": false 77 | }], 78 | "realname": "Joe Smith", 79 | "status": "active", 80 | "tokens": [{ 81 | "serial": "0", 82 | "token_id": "DHIZ34ALBA2445ND4AI2", 83 | "type": "d1" 84 | }], 85 | "user_id": "DU3RP9I2WOC59VZX672N", 86 | "username": "jsmith" 87 | }] 88 | }` 89 | 90 | const getUserResponse = `{ 91 | "stat": "OK", 92 | "response": { 93 | "alias1": "joe.smith", 94 | "alias2": "jsmith@example.com", 95 | "alias3": null, 96 | "alias4": null, 97 | "created": 1489612729, 98 | "email": "jsmith@example.com", 99 | "firstname": "Joe", 100 | "groups": [{ 101 | "desc": "People with hardware tokens", 102 | "name": "token_users" 103 | }], 104 | "last_directory_sync": 1508789163, 105 | "last_login": 1343921403, 106 | "lastname": "Smith", 107 | "notes": "", 108 | "phones": [{ 109 | "phone_id": "DPFZRS9FB0D46QFTM899", 110 | "number": "+15555550100", 111 | "extension": "", 112 | "name": "", 113 | "postdelay": null, 114 | "predelay": null, 115 | "type": "Mobile", 116 | "capabilities": [ 117 | "sms", 118 | "phone", 119 | "push" 120 | ], 121 | "platform": "Apple iOS", 122 | "model": "Apple iPhone", 123 | "activated": false, 124 | "last_seen": "2019-03-04T15:04:04", 125 | "sms_passcodes_sent": false 126 | }], 127 | "realname": "Joe Smith", 128 | "status": "active", 129 | "tokens": [{ 130 | "serial": "0", 131 | "token_id": "DHIZ34ALBA2445ND4AI2", 132 | "type": "d1" 133 | }], 134 | "user_id": "DU3RP9I2WOC59VZX672N", 135 | "username": "jsmith" 136 | } 137 | }` 138 | 139 | func TestUser_URLValues(t *testing.T) { 140 | type fields struct { 141 | Alias1 *string 142 | Alias2 *string 143 | Alias3 *string 144 | Alias4 *string 145 | Created uint64 146 | Email string 147 | FirstName *string 148 | Groups []Group 149 | LastDirectorySync *uint64 150 | LastLogin *uint64 151 | LastName *string 152 | Notes string 153 | Phones []Phone 154 | RealName *string 155 | Status string 156 | Tokens []Token 157 | UserID string 158 | Username string 159 | } 160 | 161 | exAlias := "smith" 162 | 163 | tests := []struct { 164 | name string 165 | fields fields 166 | want url.Values 167 | }{ 168 | { 169 | name: "Simple", 170 | fields: fields{ 171 | Username: "jsmith", 172 | Status: "active", 173 | Email: "jsmith@example.com", 174 | Notes: "this is a test user", 175 | }, 176 | want: url.Values(map[string][]string{ 177 | "username": {"jsmith"}, 178 | "status": {"active"}, 179 | "email": {"jsmith@example.com"}, 180 | "notes": {"this is a test user"}, 181 | }), 182 | }, 183 | { 184 | name: "Example with pointer", 185 | fields: fields{ 186 | Alias1: &exAlias, 187 | Username: "jsmith", 188 | }, 189 | want: url.Values(map[string][]string{ 190 | "alias1": {"smith"}, 191 | "username": {"jsmith"}, 192 | }), 193 | }, 194 | { 195 | name: "Untagged", 196 | fields: fields{ 197 | Username: "jsmith", 198 | Created: 1234, 199 | Groups: []Group{{Name: "group1"}}, 200 | Phones: []Phone{{Name: "phone1"}}, 201 | Tokens: []Token{{TokenID: "token1"}}, 202 | }, 203 | want: url.Values(map[string][]string{ 204 | "username": {"jsmith"}}, 205 | ), 206 | }, 207 | } 208 | for _, tt := range tests { 209 | t.Run(tt.name, func(t *testing.T) { 210 | u := &User{ 211 | Alias1: tt.fields.Alias1, 212 | Alias2: tt.fields.Alias2, 213 | Alias3: tt.fields.Alias3, 214 | Alias4: tt.fields.Alias4, 215 | Created: tt.fields.Created, 216 | Email: tt.fields.Email, 217 | FirstName: tt.fields.FirstName, 218 | Groups: tt.fields.Groups, 219 | LastDirectorySync: tt.fields.LastDirectorySync, 220 | LastLogin: tt.fields.LastLogin, 221 | LastName: tt.fields.LastName, 222 | Notes: tt.fields.Notes, 223 | Phones: tt.fields.Phones, 224 | RealName: tt.fields.RealName, 225 | Status: tt.fields.Status, 226 | Tokens: tt.fields.Tokens, 227 | UserID: tt.fields.UserID, 228 | Username: tt.fields.Username, 229 | } 230 | if got := u.URLValues(); !reflect.DeepEqual(got, tt.want) { 231 | t.Errorf("User.URLValues() = %v, want %v", got, tt.want) 232 | } 233 | }) 234 | } 235 | } 236 | 237 | func TestGetUsers(t *testing.T) { 238 | var last_request *http.Request 239 | ts := httptest.NewTLSServer( 240 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 241 | fmt.Fprintln(w, getUsersResponse) 242 | last_request = r 243 | }), 244 | ) 245 | defer ts.Close() 246 | 247 | duo := buildAdminClient(ts.URL, nil) 248 | 249 | result, err := duo.GetUsers() 250 | if err != nil { 251 | t.Errorf("Unexpected error from GetUsers call %v", err.Error()) 252 | } 253 | if result.Stat != "OK" { 254 | t.Errorf("Expected OK, but got %s", result.Stat) 255 | } 256 | if len(result.Response) != 1 { 257 | t.Errorf("Expected 1 user, but got %d", len(result.Response)) 258 | } 259 | if result.Response[0].UserID != "DU3RP9I2WOC59VZX672N" { 260 | t.Errorf("Expected user ID DU3RP9I2WOC59VZX672N, but got %s", result.Response[0].UserID) 261 | } 262 | 263 | request_query := last_request.URL.Query() 264 | if request_query["limit"][0] != "100" { 265 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 266 | } 267 | if request_query["offset"][0] != "0" { 268 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 269 | } 270 | } 271 | 272 | const createUserResponse = `{ 273 | "stat": "OK", 274 | "response": { 275 | "alias1": null, 276 | "alias2": null, 277 | "alias3": null, 278 | "alias4": null, 279 | "created": 1489612729, 280 | "email": "jsmith@example.com", 281 | "firstname": null, 282 | "groups": [], 283 | "last_directory_sync": null, 284 | "last_login": null, 285 | "lastname": null, 286 | "notes": "", 287 | "phones": [], 288 | "realname": null, 289 | "status": "active", 290 | "tokens": [], 291 | "user_id": "DU3RP9I2WOC59VZX672N", 292 | "username": "jsmith" 293 | } 294 | }` 295 | 296 | func TestCreateUser(t *testing.T) { 297 | ts := httptest.NewTLSServer( 298 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 299 | fmt.Fprintln(w, createUserResponse) 300 | }), 301 | ) 302 | defer ts.Close() 303 | 304 | duo := buildAdminClient(ts.URL, nil) 305 | 306 | userToCreate := User{ 307 | Username: "jsmith", 308 | Email: "jsmith@example.com", 309 | Status: "active", 310 | } 311 | 312 | result, err := duo.CreateUser(userToCreate.URLValues()) 313 | if err != nil { 314 | t.Errorf("Unexpected error from CreateUser call %v", err.Error()) 315 | } 316 | if result.Stat != "OK" { 317 | t.Errorf("Expected OK, but got %s", result.Stat) 318 | } 319 | if result.Response.Username != userToCreate.Username { 320 | t.Errorf("Expected Username to be %s, but got %s", userToCreate.Username, result.Response.Username) 321 | } 322 | if result.Response.Email != userToCreate.Email { 323 | t.Errorf("Expected Email to be %s, but got %s", userToCreate.Email, result.Response.Email) 324 | } 325 | } 326 | 327 | const modifyUserResponse = `{ 328 | "stat": "OK", 329 | "response": { 330 | "alias1": "joe.smith", 331 | "alias2": "jsmith@example.com", 332 | "alias3": null, 333 | "alias4": null, 334 | "created": 1489612729, 335 | "email": "jsmith-new@example.com", 336 | "firstname": "Joe", 337 | "groups": [{ 338 | "desc": "People with hardware tokens", 339 | "name": "token_users" 340 | }], 341 | "last_directory_sync": 1508789163, 342 | "last_login": 1343921403, 343 | "lastname": "Smith", 344 | "notes": "", 345 | "phones": [{ 346 | "phone_id": "DPFZRS9FB0D46QFTM899", 347 | "number": "+15555550100", 348 | "extension": "", 349 | "name": "", 350 | "postdelay": null, 351 | "predelay": null, 352 | "type": "Mobile", 353 | "capabilities": [ 354 | "sms", 355 | "phone", 356 | "push" 357 | ], 358 | "platform": "Apple iOS", 359 | "activated": false, 360 | "sms_passcodes_sent": false 361 | }], 362 | "realname": "Joe Smith", 363 | "status": "active", 364 | "tokens": [{ 365 | "serial": "0", 366 | "token_id": "DHIZ34ALBA2445ND4AI2", 367 | "type": "d1" 368 | }], 369 | "user_id": "DU3RP9I2WOC59VZX672N", 370 | "username": "jsmith" 371 | } 372 | }` 373 | 374 | func TestModifyUser(t *testing.T) { 375 | ts := httptest.NewTLSServer( 376 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 377 | fmt.Fprintln(w, modifyUserResponse) 378 | }), 379 | ) 380 | defer ts.Close() 381 | 382 | duo := buildAdminClient(ts.URL, nil) 383 | 384 | userToModify := User{ 385 | UserID: "DU3RP9I2WOC59VZX672N", 386 | Email: "jsmith-new@example.com", 387 | } 388 | 389 | result, err := duo.ModifyUser(userToModify.UserID, userToModify.URLValues()) 390 | if err != nil { 391 | t.Errorf("Unexpected error from ModifyUser call %v", err.Error()) 392 | } 393 | if result.Stat != "OK" { 394 | t.Errorf("Expected OK, but got %s", result.Stat) 395 | } 396 | if result.Response.UserID != userToModify.UserID { 397 | t.Errorf("Expected UserID to be %s, but got %s", userToModify.UserID, result.Response.UserID) 398 | } 399 | if result.Response.Email != userToModify.Email { 400 | t.Errorf("Expected Email to be %s, but got %s", userToModify.Email, result.Response.Email) 401 | } 402 | } 403 | 404 | const deleteUserResponse = `{ 405 | "stat": "OK", 406 | "response": "" 407 | }` 408 | 409 | func TestDeleteUser(t *testing.T) { 410 | ts := httptest.NewTLSServer( 411 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 412 | fmt.Fprintln(w, deleteUserResponse) 413 | }), 414 | ) 415 | defer ts.Close() 416 | 417 | duo := buildAdminClient(ts.URL, nil) 418 | 419 | result, err := duo.DeleteUser("DU3RP9I2WOC59VZX672N") 420 | if err != nil { 421 | t.Errorf("Unexpected error from DeleteUser call %v", err.Error()) 422 | } 423 | if result.Stat != "OK" { 424 | t.Errorf("Expected OK, but got %s", result.Stat) 425 | } 426 | } 427 | 428 | const getUsersPage1Response = `{ 429 | "stat": "OK", 430 | "metadata": { 431 | "prev_offset": null, 432 | "next_offset": 1, 433 | "total_objects": 2 434 | }, 435 | "response": [{ 436 | "alias1": "joe.smith", 437 | "alias2": "jsmith@example.com", 438 | "alias3": null, 439 | "alias4": null, 440 | "created": 1489612729, 441 | "email": "jsmith@example.com", 442 | "firstname": "Joe", 443 | "groups": [{ 444 | "desc": "People with hardware tokens", 445 | "name": "token_users" 446 | }], 447 | "last_directory_sync": 1508789163, 448 | "last_login": 1343921403, 449 | "lastname": "Smith", 450 | "notes": "", 451 | "phones": [{ 452 | "phone_id": "DPFZRS9FB0D46QFTM899", 453 | "number": "+15555550100", 454 | "extension": "", 455 | "name": "", 456 | "postdelay": null, 457 | "predelay": null, 458 | "type": "Mobile", 459 | "capabilities": [ 460 | "sms", 461 | "phone", 462 | "push" 463 | ], 464 | "platform": "Apple iOS", 465 | "model": "Apple iPhone", 466 | "activated": false, 467 | "last_seen": "2019-03-04T15:04:04", 468 | "sms_passcodes_sent": false 469 | }], 470 | "realname": "Joe Smith", 471 | "status": "active", 472 | "tokens": [{ 473 | "serial": "0", 474 | "token_id": "DHIZ34ALBA2445ND4AI2", 475 | "type": "d1" 476 | }], 477 | "user_id": "DU3RP9I2WOC59VZX672N", 478 | "username": "jsmith" 479 | }] 480 | }` 481 | 482 | const getUsersPage2Response = `{ 483 | "stat": "OK", 484 | "metadata": { 485 | "prev_offset": null, 486 | "next_offset": null, 487 | "total_objects": 2 488 | }, 489 | "response": [{ 490 | "alias1": "joe.smith", 491 | "alias2": "jsmith@example.com", 492 | "alias3": null, 493 | "alias4": null, 494 | "created": 1489612729, 495 | "email": "jsmith@example.com", 496 | "firstname": "Joe", 497 | "groups": [{ 498 | "desc": "People with hardware tokens", 499 | "name": "token_users" 500 | }], 501 | "last_directory_sync": 1508789163, 502 | "last_login": 1343921403, 503 | "lastname": "Smith", 504 | "notes": "", 505 | "phones": [{ 506 | "phone_id": "DPFZRS9FB0D46QFTM899", 507 | "number": "+15555550100", 508 | "extension": "", 509 | "name": "", 510 | "postdelay": null, 511 | "predelay": null, 512 | "type": "Mobile", 513 | "capabilities": [ 514 | "sms", 515 | "phone", 516 | "push" 517 | ], 518 | "platform": "Apple iOS", 519 | "model": "Apple iPhone", 520 | "activated": false, 521 | "last_seen": "2019-03-04T15:04:04", 522 | "sms_passcodes_sent": false 523 | }], 524 | "realname": "Joe Smith", 525 | "status": "active", 526 | "tokens": [{ 527 | "serial": "0", 528 | "token_id": "DHIZ34ALBA2445ND4AI2", 529 | "type": "d1" 530 | }], 531 | "user_id": "DU3RP9I2WOC59VZX672N", 532 | "username": "jsmith" 533 | }] 534 | }` 535 | 536 | func TestGetUsersMultipage(t *testing.T) { 537 | requests := []*http.Request{} 538 | ts := httptest.NewTLSServer( 539 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 540 | if len(requests) == 0 { 541 | fmt.Fprintln(w, getUsersPage1Response) 542 | } else { 543 | fmt.Fprintln(w, getUsersPage2Response) 544 | } 545 | requests = append(requests, r) 546 | }), 547 | ) 548 | defer ts.Close() 549 | 550 | duo := buildAdminClient(ts.URL, nil) 551 | 552 | result, err := duo.GetUsers() 553 | 554 | if len(requests) != 2 { 555 | t.Errorf("Expected two requets, found %d", len(requests)) 556 | } 557 | 558 | if result.Metadata.TotalObjects != "2" { 559 | t.Errorf("Expected total obects to be two, found %s", result.Metadata.TotalObjects) 560 | } 561 | 562 | if len(result.Response) != 2 { 563 | t.Errorf("Expected two users in the response, found %d", len(result.Response)) 564 | } 565 | 566 | if err != nil { 567 | t.Errorf("Expected err to be nil, found %s", err) 568 | } 569 | } 570 | 571 | const getEmptyPageArgsResponse = `{ 572 | "stat": "OK", 573 | "metadata": { 574 | "prev_offset": null, 575 | "next_offset": 2, 576 | "total_objects": 2 577 | }, 578 | "response": [] 579 | }` 580 | 581 | func TestGetUserPageArgs(t *testing.T) { 582 | requests := []*http.Request{} 583 | ts := httptest.NewTLSServer( 584 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 585 | fmt.Fprintln(w, getEmptyPageArgsResponse) 586 | requests = append(requests, r) 587 | }), 588 | ) 589 | 590 | defer ts.Close() 591 | 592 | duo := buildAdminClient(ts.URL, nil) 593 | 594 | _, err := duo.GetUsers(func(values *url.Values) { 595 | values.Set("limit", "200") 596 | values.Set("offset", "1") 597 | return 598 | }) 599 | 600 | if err != nil { 601 | t.Errorf("Encountered unexpected error: %s", err) 602 | } 603 | 604 | if len(requests) != 1 { 605 | t.Errorf("Expected there to be one request, found %d", len(requests)) 606 | } 607 | request := requests[0] 608 | request_query := request.URL.Query() 609 | if request_query["limit"][0] != "200" { 610 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 611 | } 612 | if request_query["offset"][0] != "1" { 613 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 614 | } 615 | } 616 | 617 | func TestGetUser(t *testing.T) { 618 | ts := httptest.NewTLSServer( 619 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 620 | fmt.Fprintln(w, getUserResponse) 621 | }), 622 | ) 623 | defer ts.Close() 624 | 625 | duo := buildAdminClient(ts.URL, nil) 626 | 627 | result, err := duo.GetUser("DU3RP9I2WOC59VZX672N") 628 | if err != nil { 629 | t.Errorf("Unexpected error from GetUser call %v", err.Error()) 630 | } 631 | if result.Stat != "OK" { 632 | t.Errorf("Expected OK, but got %s", result.Stat) 633 | } 634 | if result.Response.UserID != "DU3RP9I2WOC59VZX672N" { 635 | t.Errorf("Expected user ID DU3RP9I2WOC59VZX672N, but got %s", result.Response.UserID) 636 | } 637 | } 638 | 639 | const getGroupsResponse = `{ 640 | "response": [{ 641 | "desc": "This is group A", 642 | "group_id": "DGXXXXXXXXXXXXXXXXXA", 643 | "name": "Group A", 644 | "push_enabled": true, 645 | "sms_enabled": true, 646 | "status": "active", 647 | "voice_enabled": true, 648 | "mobile_otp_enabled": true 649 | }, 650 | { 651 | "desc": "This is group B", 652 | "group_id": "DGXXXXXXXXXXXXXXXXXB", 653 | "name": "Group B", 654 | "push_enabled": true, 655 | "sms_enabled": true, 656 | "status": "active", 657 | "voice_enabled": true, 658 | "mobile_otp_enabled": true 659 | }], 660 | "stat": "OK", 661 | "metadata": { 662 | "prev_offset": null, 663 | "next_offset": null, 664 | "total_objects": 2 665 | } 666 | }` 667 | 668 | func TestGetUserGroups(t *testing.T) { 669 | var last_request *http.Request 670 | ts := httptest.NewTLSServer( 671 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 672 | fmt.Fprintln(w, getGroupsResponse) 673 | last_request = r 674 | }), 675 | ) 676 | defer ts.Close() 677 | 678 | duo := buildAdminClient(ts.URL, nil) 679 | 680 | result, err := duo.GetUserGroups("DU3RP9I2WOC59VZX672N") 681 | if err != nil { 682 | t.Errorf("Unexpected error from GetUserGroups call %v", err.Error()) 683 | } 684 | if result.Stat != "OK" { 685 | t.Errorf("Expected OK, but got %s", result.Stat) 686 | } 687 | if len(result.Response) != 2 { 688 | t.Errorf("Expected 2 groups, but got %d", len(result.Response)) 689 | } 690 | if result.Response[0].GroupID != "DGXXXXXXXXXXXXXXXXXA" { 691 | t.Errorf("Expected group ID DGXXXXXXXXXXXXXXXXXA, but got %s", result.Response[0].GroupID) 692 | } 693 | 694 | request_query := last_request.URL.Query() 695 | if request_query["limit"][0] != "100" { 696 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 697 | } 698 | if request_query["offset"][0] != "0" { 699 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 700 | } 701 | } 702 | 703 | const getGroupsPage1Response = `{ 704 | "response": [{ 705 | "desc": "This is group A", 706 | "group_id": "DGXXXXXXXXXXXXXXXXXA", 707 | "name": "Group A", 708 | "push_enabled": true, 709 | "sms_enabled": true, 710 | "status": "active", 711 | "voice_enabled": true, 712 | "mobile_otp_enabled": true 713 | }, 714 | { 715 | "desc": "This is group B", 716 | "group_id": "DGXXXXXXXXXXXXXXXXXB", 717 | "name": "Group B", 718 | "push_enabled": true, 719 | "sms_enabled": true, 720 | "status": "active", 721 | "voice_enabled": true, 722 | "mobile_otp_enabled": true 723 | }], 724 | "stat": "OK", 725 | "metadata": { 726 | "prev_offset": null, 727 | "next_offset": 2, 728 | "total_objects": 4 729 | } 730 | }` 731 | 732 | const getGroupsPage2Response = `{ 733 | "response": [{ 734 | "desc": "This is group C", 735 | "group_id": "DGXXXXXXXXXXXXXXXXXC", 736 | "name": "Group C", 737 | "push_enabled": true, 738 | "sms_enabled": true, 739 | "status": "active", 740 | "voice_enabled": true, 741 | "mobile_otp_enabled": true 742 | }, 743 | { 744 | "desc": "This is group D", 745 | "group_id": "DGXXXXXXXXXXXXXXXXXD", 746 | "name": "Group D", 747 | "push_enabled": true, 748 | "sms_enabled": true, 749 | "status": "active", 750 | "voice_enabled": true, 751 | "mobile_otp_enabled": true 752 | }], 753 | "stat": "OK", 754 | "metadata": { 755 | "prev_offset": 0, 756 | "next_offset": null, 757 | "total_objects": 4 758 | } 759 | }` 760 | 761 | func TestGetUserGroupsMultiple(t *testing.T) { 762 | requests := []*http.Request{} 763 | ts := httptest.NewTLSServer( 764 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 765 | if len(requests) == 0 { 766 | fmt.Fprintln(w, getGroupsPage1Response) 767 | } else { 768 | fmt.Fprintln(w, getGroupsPage2Response) 769 | } 770 | requests = append(requests, r) 771 | }), 772 | ) 773 | defer ts.Close() 774 | 775 | duo := buildAdminClient(ts.URL, nil) 776 | 777 | result, err := duo.GetUserGroups("DU3RP9I2WOC59VZX672N") 778 | 779 | if len(requests) != 2 { 780 | t.Errorf("Expected two requets, found %d", len(requests)) 781 | } 782 | 783 | if len(result.Response) != 4 { 784 | t.Errorf("Expected four groups in the response, found %d", len(result.Response)) 785 | } 786 | 787 | if err != nil { 788 | t.Errorf("Expected err to be nil, found %s", err) 789 | } 790 | } 791 | 792 | func TestGetUserGroupsPageArgs(t *testing.T) { 793 | requests := []*http.Request{} 794 | ts := httptest.NewTLSServer( 795 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 796 | fmt.Fprintln(w, getEmptyPageArgsResponse) 797 | requests = append(requests, r) 798 | }), 799 | ) 800 | 801 | defer ts.Close() 802 | 803 | duo := buildAdminClient(ts.URL, nil) 804 | 805 | _, err := duo.GetUserGroups("DU3RP9I2WOC59VZX672N", func(values *url.Values) { 806 | values.Set("limit", "200") 807 | values.Set("offset", "1") 808 | return 809 | }) 810 | 811 | if err != nil { 812 | t.Errorf("Encountered unexpected error: %s", err) 813 | } 814 | 815 | if len(requests) != 1 { 816 | t.Errorf("Expected there to be one request, found %d", len(requests)) 817 | } 818 | request := requests[0] 819 | request_query := request.URL.Query() 820 | if request_query["limit"][0] != "200" { 821 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 822 | } 823 | if request_query["offset"][0] != "1" { 824 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 825 | } 826 | } 827 | 828 | const associateGroupWithUserResponse = `{ 829 | "stat": "OK", 830 | "response": "" 831 | }` 832 | 833 | func TestAssociateGroupWithUser(t *testing.T) { 834 | ts := httptest.NewTLSServer( 835 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 836 | fmt.Fprintln(w, associateGroupWithUserResponse) 837 | }), 838 | ) 839 | defer ts.Close() 840 | 841 | duo := buildAdminClient(ts.URL, nil) 842 | 843 | result, err := duo.AssociateGroupWithUser("DU3RP9I2WOC59VZX672N", "DGXXXXXXXXXXXXXXXXXX") 844 | if err != nil { 845 | t.Errorf("Unexpected error from AssociateGroupWithUser call %v", err.Error()) 846 | } 847 | if result.Stat != "OK" { 848 | t.Errorf("Expected OK, but got %s", result.Stat) 849 | } 850 | } 851 | 852 | const disassociateGroupFromUserResponse = `{ 853 | "stat": "OK", 854 | "response": "" 855 | }` 856 | 857 | func TestDisassociateGroupFromUser(t *testing.T) { 858 | ts := httptest.NewTLSServer( 859 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 860 | fmt.Fprintln(w, associateGroupWithUserResponse) 861 | }), 862 | ) 863 | defer ts.Close() 864 | 865 | duo := buildAdminClient(ts.URL, nil) 866 | 867 | result, err := duo.DisassociateGroupFromUser("DU3RP9I2WOC59VZX672N", "DGXXXXXXXXXXXXXXXXXX") 868 | if err != nil { 869 | t.Errorf("Unexpected error from DisassociateGroupFromUser call %v", err.Error()) 870 | } 871 | if result.Stat != "OK" { 872 | t.Errorf("Expected OK, but got %s", result.Stat) 873 | } 874 | } 875 | 876 | const getUserPhonesResponse = `{ 877 | "stat": "OK", 878 | "response": [{ 879 | "activated": false, 880 | "last_seen": "2019-03-04T15:04:04", 881 | "capabilities": [ 882 | "sms", 883 | "phone", 884 | "push" 885 | ], 886 | "extension": "", 887 | "name": "", 888 | "number": "+15035550102", 889 | "phone_id": "DPFZRS9FB0D46QFTM890", 890 | "platform": "Apple iOS", 891 | "model": "Apple iPhone", 892 | "postdelay": null, 893 | "predelay": null, 894 | "sms_passcodes_sent": false, 895 | "type": "Mobile" 896 | }, 897 | { 898 | "activated": false, 899 | "last_seen": "2019-03-04T15:04:04", 900 | "capabilities": [ 901 | "phone" 902 | ], 903 | "extension": "", 904 | "name": "", 905 | "number": "+15035550103", 906 | "phone_id": "DPFZRS9FB0D46QFTM891", 907 | "platform": "Unknown", 908 | "model": "Unknown", 909 | "postdelay": null, 910 | "predelay": null, 911 | "sms_passcodes_sent": false, 912 | "type": "Landline" 913 | }], 914 | "metadata": { 915 | "prev_offset": null, 916 | "next_offset": null, 917 | "total_objects": 2 918 | } 919 | }` 920 | 921 | func TestGetUserPhones(t *testing.T) { 922 | var last_request *http.Request 923 | ts := httptest.NewTLSServer( 924 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 925 | fmt.Fprintln(w, getUserPhonesResponse) 926 | last_request = r 927 | }), 928 | ) 929 | defer ts.Close() 930 | 931 | duo := buildAdminClient(ts.URL, nil) 932 | 933 | result, err := duo.GetUserPhones("DU3RP9I2WOC59VZX672N") 934 | if err != nil { 935 | t.Errorf("Unexpected error from GetUserPhones call %v", err.Error()) 936 | } 937 | if result.Stat != "OK" { 938 | t.Errorf("Expected OK, but got %s", result.Stat) 939 | } 940 | if len(result.Response) != 2 { 941 | t.Errorf("Expected 2 phones, but got %d", len(result.Response)) 942 | } 943 | if result.Response[0].PhoneID != "DPFZRS9FB0D46QFTM890" { 944 | t.Errorf("Expected phone ID DPFZRS9FB0D46QFTM890, but got %s", result.Response[0].PhoneID) 945 | } 946 | 947 | request_query := last_request.URL.Query() 948 | if request_query["limit"][0] != "100" { 949 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 950 | } 951 | if request_query["offset"][0] != "0" { 952 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 953 | } 954 | } 955 | 956 | const getUserPhonesPage1Response = `{ 957 | "stat": "OK", 958 | "response": [{ 959 | "activated": false, 960 | "last_seen": "2019-03-04T15:04:04", 961 | "capabilities": [ 962 | "sms", 963 | "phone", 964 | "push" 965 | ], 966 | "extension": "", 967 | "name": "", 968 | "number": "+15035550102", 969 | "phone_id": "DPFZRS9FB0D46QFTM890", 970 | "platform": "Apple iOS", 971 | "model": "Apple iPhone", 972 | "postdelay": null, 973 | "predelay": null, 974 | "sms_passcodes_sent": false, 975 | "type": "Mobile" 976 | }, 977 | { 978 | "activated": false, 979 | "last_seen": "2019-03-04T15:04:04", 980 | "capabilities": [ 981 | "phone" 982 | ], 983 | "extension": "", 984 | "name": "", 985 | "number": "+15035550103", 986 | "phone_id": "DPFZRS9FB0D46QFTM891", 987 | "platform": "Unknown", 988 | "model": "Unknown", 989 | "postdelay": null, 990 | "predelay": null, 991 | "sms_passcodes_sent": false, 992 | "type": "Landline" 993 | }], 994 | "metadata": { 995 | "prev_offset": null, 996 | "next_offset": 2, 997 | "total_objects": 4 998 | } 999 | }` 1000 | 1001 | const getUserPhonesPage2Response = `{ 1002 | "stat": "OK", 1003 | "response": [{ 1004 | "activated": false, 1005 | "last_seen": "2019-03-04T15:04:04", 1006 | "capabilities": [ 1007 | "sms", 1008 | "phone", 1009 | "push" 1010 | ], 1011 | "extension": "", 1012 | "name": "", 1013 | "number": "+15035550102", 1014 | "phone_id": "DPFZRS9FB0D46QFTM890", 1015 | "platform": "Apple iOS", 1016 | "model": "Apple iPhone", 1017 | "postdelay": null, 1018 | "predelay": null, 1019 | "sms_passcodes_sent": false, 1020 | "type": "Mobile" 1021 | }, 1022 | { 1023 | "activated": false, 1024 | "last_seen": "2019-03-04T15:04:04", 1025 | "capabilities": [ 1026 | "phone" 1027 | ], 1028 | "extension": "", 1029 | "name": "", 1030 | "number": "+15035550103", 1031 | "phone_id": "DPFZRS9FB0D46QFTM891", 1032 | "platform": "Unknown", 1033 | "model": "Unknown", 1034 | "postdelay": null, 1035 | "predelay": null, 1036 | "sms_passcodes_sent": false, 1037 | "type": "Landline" 1038 | }], 1039 | "metadata": { 1040 | "prev_offset": 0, 1041 | "next_offset": null, 1042 | "total_objects": 4 1043 | } 1044 | }` 1045 | 1046 | func TestGetUserPhonesMultiple(t *testing.T) { 1047 | requests := []*http.Request{} 1048 | ts := httptest.NewTLSServer( 1049 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1050 | if len(requests) == 0 { 1051 | fmt.Fprintln(w, getUserPhonesPage1Response) 1052 | } else { 1053 | fmt.Fprintln(w, getUserPhonesPage2Response) 1054 | } 1055 | requests = append(requests, r) 1056 | }), 1057 | ) 1058 | defer ts.Close() 1059 | 1060 | duo := buildAdminClient(ts.URL, nil) 1061 | 1062 | result, err := duo.GetUserPhones("DU3RP9I2WOC59VZX672N") 1063 | 1064 | if len(requests) != 2 { 1065 | t.Errorf("Expected two requets, found %d", len(requests)) 1066 | } 1067 | 1068 | if len(result.Response) != 4 { 1069 | t.Errorf("Expected four phones in the response, found %d", len(result.Response)) 1070 | } 1071 | 1072 | if err != nil { 1073 | t.Errorf("Expected err to be nil, found %s", err) 1074 | } 1075 | } 1076 | 1077 | func TestGetUserPhonesPageArgs(t *testing.T) { 1078 | requests := []*http.Request{} 1079 | ts := httptest.NewTLSServer( 1080 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1081 | fmt.Fprintln(w, getEmptyPageArgsResponse) 1082 | requests = append(requests, r) 1083 | }), 1084 | ) 1085 | 1086 | defer ts.Close() 1087 | 1088 | duo := buildAdminClient(ts.URL, nil) 1089 | 1090 | _, err := duo.GetUserPhones("DU3RP9I2WOC59VZX672N", func(values *url.Values) { 1091 | values.Set("limit", "200") 1092 | values.Set("offset", "1") 1093 | return 1094 | }) 1095 | 1096 | if err != nil { 1097 | t.Errorf("Encountered unexpected error: %s", err) 1098 | } 1099 | 1100 | if len(requests) != 1 { 1101 | t.Errorf("Expected there to be one request, found %d", len(requests)) 1102 | } 1103 | request := requests[0] 1104 | request_query := request.URL.Query() 1105 | if request_query["limit"][0] != "200" { 1106 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1107 | } 1108 | if request_query["offset"][0] != "1" { 1109 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1110 | } 1111 | } 1112 | 1113 | const getUserTokensResponse = `{ 1114 | "stat": "OK", 1115 | "response": [{ 1116 | "type": "d1", 1117 | "serial": "0", 1118 | "token_id": "DHEKH0JJIYC1LX3AZWO4" 1119 | }, 1120 | { 1121 | "type": "d1", 1122 | "serial": "7", 1123 | "token_id": "DHUNT3ZVS3ACF8AEV2WG", 1124 | "totp_step": null 1125 | }], 1126 | "metadata": { 1127 | "prev_offset": null, 1128 | "next_offset": null, 1129 | "total_objects": 2 1130 | } 1131 | }` 1132 | 1133 | func TestGetUserTokens(t *testing.T) { 1134 | var last_request *http.Request 1135 | ts := httptest.NewTLSServer( 1136 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1137 | fmt.Fprintln(w, getUserTokensResponse) 1138 | last_request = r 1139 | }), 1140 | ) 1141 | defer ts.Close() 1142 | 1143 | duo := buildAdminClient(ts.URL, nil) 1144 | 1145 | result, err := duo.GetUserTokens("DU3RP9I2WOC59VZX672N") 1146 | if err != nil { 1147 | t.Errorf("Unexpected error from GetUserTokens call %v", err.Error()) 1148 | } 1149 | if result.Stat != "OK" { 1150 | t.Errorf("Expected OK, but got %s", result.Stat) 1151 | } 1152 | if len(result.Response) != 2 { 1153 | t.Errorf("Expected 2 tokens, but got %d", len(result.Response)) 1154 | } 1155 | if result.Response[0].TokenID != "DHEKH0JJIYC1LX3AZWO4" { 1156 | t.Errorf("Expected token ID DHEKH0JJIYC1LX3AZWO4, but got %s", result.Response[0].TokenID) 1157 | } 1158 | 1159 | request_query := last_request.URL.Query() 1160 | if request_query["limit"][0] != "100" { 1161 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1162 | } 1163 | if request_query["offset"][0] != "0" { 1164 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1165 | } 1166 | } 1167 | 1168 | const getUserTokensPage1Response = `{ 1169 | "stat": "OK", 1170 | "response": [{ 1171 | "type": "d1", 1172 | "serial": "0", 1173 | "token_id": "DHEKH0JJIYC1LX3AZWO4" 1174 | }, 1175 | { 1176 | "type": "d1", 1177 | "serial": "7", 1178 | "token_id": "DHUNT3ZVS3ACF8AEV2WG", 1179 | "totp_step": null 1180 | }], 1181 | "metadata": { 1182 | "prev_offset": null, 1183 | "next_offset": 2, 1184 | "total_objects": 4 1185 | } 1186 | }` 1187 | 1188 | const getUserTokensPage2Response = `{ 1189 | "stat": "OK", 1190 | "response": [{ 1191 | "type": "d1", 1192 | "serial": "0", 1193 | "token_id": "DHEKH0JJIYC1LX3AZWO4" 1194 | }, 1195 | { 1196 | "type": "d1", 1197 | "serial": "7", 1198 | "token_id": "DHUNT3ZVS3ACF8AEV2WG", 1199 | "totp_step": null 1200 | }], 1201 | "metadata": { 1202 | "prev_offset": 0, 1203 | "next_offset": null, 1204 | "total_objects": 4 1205 | } 1206 | }` 1207 | 1208 | func TestGetUserTokensMultiple(t *testing.T) { 1209 | requests := []*http.Request{} 1210 | ts := httptest.NewTLSServer( 1211 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1212 | if len(requests) == 0 { 1213 | fmt.Fprintln(w, getUserTokensPage1Response) 1214 | } else { 1215 | fmt.Fprintln(w, getUserTokensPage2Response) 1216 | } 1217 | requests = append(requests, r) 1218 | }), 1219 | ) 1220 | defer ts.Close() 1221 | 1222 | duo := buildAdminClient(ts.URL, nil) 1223 | 1224 | result, err := duo.GetUserTokens("DU3RP9I2WOC59VZX672N") 1225 | 1226 | if len(requests) != 2 { 1227 | t.Errorf("Expected two requets, found %d", len(requests)) 1228 | } 1229 | 1230 | if len(result.Response) != 4 { 1231 | t.Errorf("Expected four tokens in the response, found %d", len(result.Response)) 1232 | } 1233 | 1234 | if err != nil { 1235 | t.Errorf("Expected err to be nil, found %s", err) 1236 | } 1237 | } 1238 | 1239 | func TestGetUserTokensPageArgs(t *testing.T) { 1240 | requests := []*http.Request{} 1241 | ts := httptest.NewTLSServer( 1242 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1243 | fmt.Fprintln(w, getEmptyPageArgsResponse) 1244 | requests = append(requests, r) 1245 | }), 1246 | ) 1247 | 1248 | defer ts.Close() 1249 | 1250 | duo := buildAdminClient(ts.URL, nil) 1251 | 1252 | _, err := duo.GetUserTokens("DU3RP9I2WOC59VZX672N", func(values *url.Values) { 1253 | values.Set("limit", "200") 1254 | values.Set("offset", "1") 1255 | return 1256 | }) 1257 | 1258 | if err != nil { 1259 | t.Errorf("Encountered unexpected error: %s", err) 1260 | } 1261 | 1262 | if len(requests) != 1 { 1263 | t.Errorf("Expected there to be one request, found %d", len(requests)) 1264 | } 1265 | request := requests[0] 1266 | request_query := request.URL.Query() 1267 | if request_query["limit"][0] != "200" { 1268 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1269 | } 1270 | if request_query["offset"][0] != "1" { 1271 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1272 | } 1273 | } 1274 | 1275 | const associateUserTokenResponse = `{ 1276 | "stat": "OK", 1277 | "response": "" 1278 | }` 1279 | 1280 | func TestAssociateUserToken(t *testing.T) { 1281 | ts := httptest.NewTLSServer( 1282 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1283 | fmt.Fprintln(w, associateUserTokenResponse) 1284 | }), 1285 | ) 1286 | defer ts.Close() 1287 | 1288 | duo := buildAdminClient(ts.URL, nil) 1289 | 1290 | result, err := duo.AssociateUserToken("DU3RP9I2WOC59VZX672N", "DHEKH0JJIYC1LX3AZWO4") 1291 | if err != nil { 1292 | t.Errorf("Unexpected error from AssociateUserToken call %v", err.Error()) 1293 | } 1294 | if result.Stat != "OK" { 1295 | t.Errorf("Expected OK, but got %s", result.Stat) 1296 | } 1297 | if len(result.Response) != 0 { 1298 | t.Errorf("Expected empty response, but got %s", result.Response) 1299 | } 1300 | } 1301 | 1302 | const getUserU2FTokensResponse = `{ 1303 | "stat": "OK", 1304 | "response": [{ 1305 | "date_added": 1444678994, 1306 | "registration_id": "D21RU6X1B1DF5P54B6PV" 1307 | }], 1308 | "metadata": { 1309 | "prev_offset": null, 1310 | "next_offset": null, 1311 | "total_objects": 1 1312 | } 1313 | }` 1314 | 1315 | func TestGetUserU2FTokens(t *testing.T) { 1316 | var last_request *http.Request 1317 | ts := httptest.NewTLSServer( 1318 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1319 | fmt.Fprintln(w, getUserU2FTokensResponse) 1320 | last_request = r 1321 | }), 1322 | ) 1323 | defer ts.Close() 1324 | 1325 | duo := buildAdminClient(ts.URL, nil) 1326 | 1327 | result, err := duo.GetUserU2FTokens("DU3RP9I2WOC59VZX672N") 1328 | if err != nil { 1329 | t.Errorf("Unexpected error from GetUserU2FTokens call %v", err.Error()) 1330 | } 1331 | if result.Stat != "OK" { 1332 | t.Errorf("Expected OK, but got %s", result.Stat) 1333 | } 1334 | if len(result.Response) != 1 { 1335 | t.Errorf("Expected 1 token, but got %d", len(result.Response)) 1336 | } 1337 | if result.Response[0].RegistrationID != "D21RU6X1B1DF5P54B6PV" { 1338 | t.Errorf("Expected registration ID D21RU6X1B1DF5P54B6PV, but got %s", result.Response[0].RegistrationID) 1339 | } 1340 | 1341 | request_query := last_request.URL.Query() 1342 | if request_query["limit"][0] != "100" { 1343 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1344 | } 1345 | if request_query["offset"][0] != "0" { 1346 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1347 | } 1348 | } 1349 | 1350 | const getUserU2FTokensPage1Response = `{ 1351 | "stat": "OK", 1352 | "response": [{ 1353 | "date_added": 1444678994, 1354 | "registration_id": "D21RU6X1B1DF5P54B6PV" 1355 | }], 1356 | "metadata": { 1357 | "prev_offset": null, 1358 | "next_offset": 1, 1359 | "total_objects": 2 1360 | } 1361 | }` 1362 | 1363 | const getUserU2FTokensPage2Response = `{ 1364 | "stat": "OK", 1365 | "response": [{ 1366 | "date_added": 1444678994, 1367 | "registration_id": "D21RU6X1B1DF5P54B6PV" 1368 | }], 1369 | "metadata": { 1370 | "prev_offset": 0, 1371 | "next_offset": null, 1372 | "total_objects": 2 1373 | } 1374 | }` 1375 | 1376 | func TestGetUserU2FTokensMultiple(t *testing.T) { 1377 | requests := []*http.Request{} 1378 | ts := httptest.NewTLSServer( 1379 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1380 | if len(requests) == 0 { 1381 | fmt.Fprintln(w, getUserU2FTokensPage1Response) 1382 | } else { 1383 | fmt.Fprintln(w, getUserU2FTokensPage2Response) 1384 | } 1385 | requests = append(requests, r) 1386 | }), 1387 | ) 1388 | defer ts.Close() 1389 | 1390 | duo := buildAdminClient(ts.URL, nil) 1391 | 1392 | result, err := duo.GetUserU2FTokens("DU3RP9I2WOC59VZX672N") 1393 | 1394 | if len(requests) != 2 { 1395 | t.Errorf("Expected two requets, found %d", len(requests)) 1396 | } 1397 | 1398 | if len(result.Response) != 2 { 1399 | t.Errorf("Expected two tokens in the response, found %d", len(result.Response)) 1400 | } 1401 | 1402 | if err != nil { 1403 | t.Errorf("Expected err to be nil, found %s", err) 1404 | } 1405 | } 1406 | 1407 | func TestGetUserU2FTokensPageArgs(t *testing.T) { 1408 | requests := []*http.Request{} 1409 | ts := httptest.NewTLSServer( 1410 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1411 | fmt.Fprintln(w, getEmptyPageArgsResponse) 1412 | requests = append(requests, r) 1413 | }), 1414 | ) 1415 | 1416 | defer ts.Close() 1417 | 1418 | duo := buildAdminClient(ts.URL, nil) 1419 | 1420 | _, err := duo.GetUserU2FTokens("DU3RP9I2WOC59VZX672N", func(values *url.Values) { 1421 | values.Set("limit", "200") 1422 | values.Set("offset", "1") 1423 | return 1424 | }) 1425 | 1426 | if err != nil { 1427 | t.Errorf("Encountered unexpected error: %s", err) 1428 | } 1429 | 1430 | if len(requests) != 1 { 1431 | t.Errorf("Expected there to be one request, found %d", len(requests)) 1432 | } 1433 | request := requests[0] 1434 | request_query := request.URL.Query() 1435 | if request_query["limit"][0] != "200" { 1436 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1437 | } 1438 | if request_query["offset"][0] != "1" { 1439 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1440 | } 1441 | } 1442 | 1443 | func TestGetGroups(t *testing.T) { 1444 | var last_request *http.Request 1445 | ts := httptest.NewTLSServer( 1446 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1447 | fmt.Fprintln(w, getGroupsResponse) 1448 | last_request = r 1449 | }), 1450 | ) 1451 | defer ts.Close() 1452 | 1453 | duo := buildAdminClient(ts.URL, nil) 1454 | 1455 | result, err := duo.GetGroups() 1456 | if err != nil { 1457 | t.Errorf("Unexpected error from GetGroups call %v", err.Error()) 1458 | } 1459 | if result.Stat != "OK" { 1460 | t.Errorf("Expected OK, but got %s", result.Stat) 1461 | } 1462 | if len(result.Response) != 2 { 1463 | t.Errorf("Expected 2 groups, but got %d", len(result.Response)) 1464 | } 1465 | if result.Response[0].Name != "Group A" { 1466 | t.Errorf("Expected group name Group A, but got %s", result.Response[0].Name) 1467 | } 1468 | 1469 | request_query := last_request.URL.Query() 1470 | if request_query["limit"][0] != "100" { 1471 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1472 | } 1473 | if request_query["offset"][0] != "0" { 1474 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1475 | } 1476 | } 1477 | 1478 | func TestGetGroupsMultiple(t *testing.T) { 1479 | requests := []*http.Request{} 1480 | ts := httptest.NewTLSServer( 1481 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1482 | if len(requests) == 0 { 1483 | fmt.Fprintln(w, getGroupsPage1Response) 1484 | } else { 1485 | fmt.Fprintln(w, getGroupsPage2Response) 1486 | } 1487 | requests = append(requests, r) 1488 | }), 1489 | ) 1490 | defer ts.Close() 1491 | 1492 | duo := buildAdminClient(ts.URL, nil) 1493 | 1494 | result, err := duo.GetGroups() 1495 | 1496 | if len(requests) != 2 { 1497 | t.Errorf("Expected two requets, found %d", len(requests)) 1498 | } 1499 | 1500 | if len(result.Response) != 4 { 1501 | t.Errorf("Expected four groups in the response, found %d", len(result.Response)) 1502 | } 1503 | 1504 | if err != nil { 1505 | t.Errorf("Expected err to be nil, found %s", err) 1506 | } 1507 | } 1508 | 1509 | func TestGetGroupsPageArgs(t *testing.T) { 1510 | requests := []*http.Request{} 1511 | ts := httptest.NewTLSServer( 1512 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1513 | fmt.Fprintln(w, getEmptyPageArgsResponse) 1514 | requests = append(requests, r) 1515 | }), 1516 | ) 1517 | 1518 | defer ts.Close() 1519 | 1520 | duo := buildAdminClient(ts.URL, nil) 1521 | 1522 | _, err := duo.GetGroups(func(values *url.Values) { 1523 | values.Set("limit", "200") 1524 | values.Set("offset", "1") 1525 | return 1526 | }) 1527 | 1528 | if err != nil { 1529 | t.Errorf("Encountered unexpected error: %s", err) 1530 | } 1531 | 1532 | if len(requests) != 1 { 1533 | t.Errorf("Expected there to be one request, found %d", len(requests)) 1534 | } 1535 | request := requests[0] 1536 | request_query := request.URL.Query() 1537 | if request_query["limit"][0] != "200" { 1538 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1539 | } 1540 | if request_query["offset"][0] != "1" { 1541 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1542 | } 1543 | } 1544 | 1545 | const getGroupResponse = `{ 1546 | "response": { 1547 | "desc": "Group description", 1548 | "group_id": "DGXXXXXXXXXXXXXXXXXX", 1549 | "name": "Group Name", 1550 | "push_enabled": true, 1551 | "sms_enabled": true, 1552 | "status": "active", 1553 | "voice_enabled": true, 1554 | "mobile_otp_enabled": true 1555 | }, 1556 | "stat": "OK" 1557 | }` 1558 | 1559 | func TestGetGroup(t *testing.T) { 1560 | ts := httptest.NewTLSServer( 1561 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1562 | fmt.Fprintln(w, getGroupResponse) 1563 | }), 1564 | ) 1565 | defer ts.Close() 1566 | 1567 | duo := buildAdminClient(ts.URL, nil) 1568 | 1569 | result, err := duo.GetGroup("DGXXXXXXXXXXXXXXXXXX") 1570 | if err != nil { 1571 | t.Errorf("Unexpected error from GetGroups call %v", err.Error()) 1572 | } 1573 | if result.Stat != "OK" { 1574 | t.Errorf("Expected OK, but got %s", result.Stat) 1575 | } 1576 | if result.Response.GroupID != "DGXXXXXXXXXXXXXXXXXX" { 1577 | t.Errorf("Expected group ID DGXXXXXXXXXXXXXXXXXX, but got %s", result.Response.GroupID) 1578 | } 1579 | if !result.Response.PushEnabled { 1580 | t.Errorf("Expected push to be enabled, but got %v", result.Response.PushEnabled) 1581 | } 1582 | } 1583 | 1584 | const getPhonesResponse = `{ 1585 | "stat": "OK", 1586 | "response": [{ 1587 | "activated": true, 1588 | "last_seen": "2019-03-04T15:04:04", 1589 | "capabilities": [ 1590 | "push", 1591 | "sms", 1592 | "phone", 1593 | "mobile_otp" 1594 | ], 1595 | "encrypted": "Encrypted", 1596 | "extension": "", 1597 | "fingerprint": "Configured", 1598 | "name": "", 1599 | "number": "+15555550100", 1600 | "phone_id": "DPFZRS9FB0D46QFTM899", 1601 | "platform": "Google Android", 1602 | "model": "Google Pixel", 1603 | "postdelay": "", 1604 | "predelay": "", 1605 | "screenlock": "Locked", 1606 | "sms_passcodes_sent": false, 1607 | "tampered": "Not tampered", 1608 | "type": "Mobile", 1609 | "users": [{ 1610 | "alias1": "joe.smith", 1611 | "alias2": "jsmith@example.com", 1612 | "alias3": null, 1613 | "alias4": null, 1614 | "email": "jsmith@example.com", 1615 | "firstname": "Joe", 1616 | "last_login": 1474399627, 1617 | "lastname": "Smith", 1618 | "notes": "", 1619 | "realname": "Joe Smith", 1620 | "status": "active", 1621 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 1622 | "username": "jsmith" 1623 | }] 1624 | }], 1625 | "metadata": { 1626 | "prev_offset": null, 1627 | "next_offset": null, 1628 | "total_objects": 1 1629 | } 1630 | }` 1631 | 1632 | func TestGetPhones(t *testing.T) { 1633 | var last_request *http.Request 1634 | ts := httptest.NewTLSServer( 1635 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1636 | fmt.Fprintln(w, getPhonesResponse) 1637 | last_request = r 1638 | }), 1639 | ) 1640 | defer ts.Close() 1641 | 1642 | duo := buildAdminClient(ts.URL, nil) 1643 | 1644 | result, err := duo.GetPhones() 1645 | if err != nil { 1646 | t.Errorf("Unexpected error from GetPhones call %v", err.Error()) 1647 | } 1648 | if result.Stat != "OK" { 1649 | t.Errorf("Expected OK, but got %s", result.Stat) 1650 | } 1651 | if len(result.Response) != 1 { 1652 | t.Errorf("Expected 1 phone, but got %d", len(result.Response)) 1653 | } 1654 | if result.Response[0].PhoneID != "DPFZRS9FB0D46QFTM899" { 1655 | t.Errorf("Expected phone ID DPFZRS9FB0D46QFTM899, but got %s", result.Response[0].PhoneID) 1656 | } 1657 | 1658 | request_query := last_request.URL.Query() 1659 | if request_query["limit"][0] != "100" { 1660 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1661 | } 1662 | if request_query["offset"][0] != "0" { 1663 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1664 | } 1665 | } 1666 | 1667 | const getPhonesPage1Response = `{ 1668 | "stat": "OK", 1669 | "response": [{ 1670 | "activated": true, 1671 | "last_seen": "2019-03-04T15:04:04", 1672 | "capabilities": [ 1673 | "push", 1674 | "sms", 1675 | "phone", 1676 | "mobile_otp" 1677 | ], 1678 | "encrypted": "Encrypted", 1679 | "extension": "", 1680 | "fingerprint": "Configured", 1681 | "name": "", 1682 | "number": "+15555550100", 1683 | "phone_id": "DPFZRS9FB0D46QFTM899", 1684 | "platform": "Google Android", 1685 | "model": "Google Pixel", 1686 | "postdelay": "", 1687 | "predelay": "", 1688 | "screenlock": "Locked", 1689 | "sms_passcodes_sent": false, 1690 | "tampered": "Not tampered", 1691 | "type": "Mobile", 1692 | "users": [{ 1693 | "alias1": "joe.smith", 1694 | "alias2": "jsmith@example.com", 1695 | "alias3": null, 1696 | "alias4": null, 1697 | "email": "jsmith@example.com", 1698 | "firstname": "Joe", 1699 | "last_login": 1474399627, 1700 | "lastname": "Smith", 1701 | "notes": "", 1702 | "realname": "Joe Smith", 1703 | "status": "active", 1704 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 1705 | "username": "jsmith" 1706 | }] 1707 | }], 1708 | "metadata": { 1709 | "prev_offset": null, 1710 | "next_offset": 1, 1711 | "total_objects": 2 1712 | } 1713 | }` 1714 | 1715 | const getPhonesPage2Response = `{ 1716 | "stat": "OK", 1717 | "response": [{ 1718 | "activated": true, 1719 | "last_seen": "2019-03-04T15:04:04", 1720 | "capabilities": [ 1721 | "push", 1722 | "sms", 1723 | "phone", 1724 | "mobile_otp" 1725 | ], 1726 | "encrypted": "Encrypted", 1727 | "extension": "", 1728 | "fingerprint": "Configured", 1729 | "name": "", 1730 | "number": "+15555550100", 1731 | "phone_id": "DPFZRS9FB0D46QFTM899", 1732 | "platform": "Google Android", 1733 | "model": "Google Pixel", 1734 | "postdelay": "", 1735 | "predelay": "", 1736 | "screenlock": "Locked", 1737 | "sms_passcodes_sent": false, 1738 | "tampered": "Not tampered", 1739 | "type": "Mobile", 1740 | "users": [{ 1741 | "alias1": "joe.smith", 1742 | "alias2": "jsmith@example.com", 1743 | "alias3": null, 1744 | "alias4": null, 1745 | "email": "jsmith@example.com", 1746 | "firstname": "Joe", 1747 | "last_login": 1474399627, 1748 | "lastname": "Smith", 1749 | "notes": "", 1750 | "realname": "Joe Smith", 1751 | "status": "active", 1752 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 1753 | "username": "jsmith" 1754 | }] 1755 | }], 1756 | "metadata": { 1757 | "prev_offset": 0, 1758 | "next_offset": null, 1759 | "total_objects": 2 1760 | } 1761 | }` 1762 | 1763 | func TestGetPhonesMultiple(t *testing.T) { 1764 | requests := []*http.Request{} 1765 | ts := httptest.NewTLSServer( 1766 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1767 | if len(requests) == 0 { 1768 | fmt.Fprintln(w, getPhonesPage1Response) 1769 | } else { 1770 | fmt.Fprintln(w, getPhonesPage2Response) 1771 | } 1772 | requests = append(requests, r) 1773 | }), 1774 | ) 1775 | defer ts.Close() 1776 | 1777 | duo := buildAdminClient(ts.URL, nil) 1778 | 1779 | result, err := duo.GetPhones() 1780 | 1781 | if len(requests) != 2 { 1782 | t.Errorf("Expected two requets, found %d", len(requests)) 1783 | } 1784 | 1785 | if len(result.Response) != 2 { 1786 | t.Errorf("Expected two phones in the response, found %d", len(result.Response)) 1787 | } 1788 | 1789 | if err != nil { 1790 | t.Errorf("Expected err to be nil, found %s", err) 1791 | } 1792 | } 1793 | 1794 | func TestGetPhonesPageArgs(t *testing.T) { 1795 | requests := []*http.Request{} 1796 | ts := httptest.NewTLSServer( 1797 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1798 | fmt.Fprintln(w, getEmptyPageArgsResponse) 1799 | requests = append(requests, r) 1800 | }), 1801 | ) 1802 | 1803 | defer ts.Close() 1804 | 1805 | duo := buildAdminClient(ts.URL, nil) 1806 | 1807 | _, err := duo.GetPhones(func(values *url.Values) { 1808 | values.Set("limit", "200") 1809 | values.Set("offset", "1") 1810 | return 1811 | }) 1812 | 1813 | if err != nil { 1814 | t.Errorf("Encountered unexpected error: %s", err) 1815 | } 1816 | 1817 | if len(requests) != 1 { 1818 | t.Errorf("Expected there to be one request, found %d", len(requests)) 1819 | } 1820 | request := requests[0] 1821 | request_query := request.URL.Query() 1822 | if request_query["limit"][0] != "200" { 1823 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1824 | } 1825 | if request_query["offset"][0] != "1" { 1826 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1827 | } 1828 | } 1829 | 1830 | const getPhoneResponse = `{ 1831 | "stat": "OK", 1832 | "response": { 1833 | "phone_id": "DPFZRS9FB0D46QFTM899", 1834 | "number": "+15555550100", 1835 | "name": "", 1836 | "extension": "", 1837 | "postdelay": null, 1838 | "predelay": null, 1839 | "type": "Mobile", 1840 | "capabilities": [ 1841 | "sms", 1842 | "phone", 1843 | "push" 1844 | ], 1845 | "platform": "Apple iOS", 1846 | "model": "Apple iPhone", 1847 | "activated": false, 1848 | "last_seen": "2019-03-04T15:04:04", 1849 | "sms_passcodes_sent": false, 1850 | "users": [{ 1851 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 1852 | "username": "jsmith", 1853 | "alias1": "joe.smith", 1854 | "alias2": "jsmith@example.com", 1855 | "realname": "Joe Smith", 1856 | "email": "jsmith@example.com", 1857 | "status": "active", 1858 | "last_login": 1343921403, 1859 | "notes": "" 1860 | }] 1861 | } 1862 | }` 1863 | 1864 | func TestGetPhone(t *testing.T) { 1865 | ts := httptest.NewTLSServer( 1866 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1867 | fmt.Fprintln(w, getPhoneResponse) 1868 | }), 1869 | ) 1870 | defer ts.Close() 1871 | 1872 | duo := buildAdminClient(ts.URL, nil) 1873 | 1874 | result, err := duo.GetPhone("DPFZRS9FB0D46QFTM899") 1875 | if err != nil { 1876 | t.Errorf("Unexpected error from GetPhone call %v", err.Error()) 1877 | } 1878 | if result.Stat != "OK" { 1879 | t.Errorf("Expected OK, but got %s", result.Stat) 1880 | } 1881 | if result.Response.PhoneID != "DPFZRS9FB0D46QFTM899" { 1882 | t.Errorf("Expected phone ID DPFZRS9FB0D46QFTM899, but got %s", result.Response.PhoneID) 1883 | } 1884 | } 1885 | 1886 | const deletePhoneResponse = `{ 1887 | "stat": "OK", 1888 | "response": "" 1889 | }` 1890 | 1891 | func TestDeletePhone(t *testing.T) { 1892 | ts := httptest.NewTLSServer( 1893 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1894 | fmt.Fprintln(w, deletePhoneResponse) 1895 | }), 1896 | ) 1897 | defer ts.Close() 1898 | 1899 | duo := buildAdminClient(ts.URL, nil) 1900 | 1901 | result, err := duo.DeletePhone("DPFZRS9FB0D46QFTM899") 1902 | if err != nil { 1903 | t.Errorf("Unexpected error from DeletePhone call %v", err.Error()) 1904 | } 1905 | if result.Stat != "OK" { 1906 | t.Errorf("Expected OK, but got %s", result.Stat) 1907 | } 1908 | } 1909 | 1910 | const getTokensResponse = `{ 1911 | "stat": "OK", 1912 | "response": [{ 1913 | "serial": "0", 1914 | "token_id": "DHIZ34ALBA2445ND4AI2", 1915 | "type": "d1", 1916 | "totp_step": null, 1917 | "users": [{ 1918 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 1919 | "username": "jsmith", 1920 | "alias1": "joe.smith", 1921 | "alias2": "jsmith@example.com", 1922 | "realname": "Joe Smith", 1923 | "email": "jsmith@example.com", 1924 | "status": "active", 1925 | "last_login": 1343921403, 1926 | "notes": "" 1927 | }] 1928 | }], 1929 | "metadata": { 1930 | "prev_offset": null, 1931 | "next_offset": null, 1932 | "total_objects": 1 1933 | } 1934 | }` 1935 | 1936 | func TestGetTokens(t *testing.T) { 1937 | var last_request *http.Request 1938 | ts := httptest.NewTLSServer( 1939 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1940 | fmt.Fprintln(w, getTokensResponse) 1941 | last_request = r 1942 | }), 1943 | ) 1944 | defer ts.Close() 1945 | 1946 | duo := buildAdminClient(ts.URL, nil) 1947 | 1948 | result, err := duo.GetTokens() 1949 | if err != nil { 1950 | t.Errorf("Unexpected error from GetTokens call %v", err.Error()) 1951 | } 1952 | if result.Stat != "OK" { 1953 | t.Errorf("Expected OK, but got %s", result.Stat) 1954 | } 1955 | if len(result.Response) != 1 { 1956 | t.Errorf("Expected 1 token, but got %d", len(result.Response)) 1957 | } 1958 | if result.Response[0].TokenID != "DHIZ34ALBA2445ND4AI2" { 1959 | t.Errorf("Expected token ID DHIZ34ALBA2445ND4AI2, but got %s", result.Response[0].TokenID) 1960 | } 1961 | 1962 | request_query := last_request.URL.Query() 1963 | if request_query["limit"][0] != "100" { 1964 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 1965 | } 1966 | if request_query["offset"][0] != "0" { 1967 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 1968 | } 1969 | } 1970 | 1971 | const getTokensPage1Response = `{ 1972 | "stat": "OK", 1973 | "response": [{ 1974 | "serial": "0", 1975 | "token_id": "DHIZ34ALBA2445ND4AI2", 1976 | "type": "d1", 1977 | "totp_step": null, 1978 | "users": [{ 1979 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 1980 | "username": "jsmith", 1981 | "alias1": "joe.smith", 1982 | "alias2": "jsmith@example.com", 1983 | "realname": "Joe Smith", 1984 | "email": "jsmith@example.com", 1985 | "status": "active", 1986 | "last_login": 1343921403, 1987 | "notes": "" 1988 | }] 1989 | }], 1990 | "metadata": { 1991 | "prev_offset": null, 1992 | "next_offset": 1, 1993 | "total_objects": 2 1994 | } 1995 | }` 1996 | 1997 | const getTokensPage2Response = `{ 1998 | "stat": "OK", 1999 | "response": [{ 2000 | "serial": "0", 2001 | "token_id": "DHIZ34ALBA2445ND4AI2", 2002 | "type": "d1", 2003 | "totp_step": null, 2004 | "users": [{ 2005 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 2006 | "username": "jsmith", 2007 | "alias1": "joe.smith", 2008 | "alias2": "jsmith@example.com", 2009 | "realname": "Joe Smith", 2010 | "email": "jsmith@example.com", 2011 | "status": "active", 2012 | "last_login": 1343921403, 2013 | "notes": "" 2014 | }] 2015 | }], 2016 | "metadata": { 2017 | "prev_offset": 0, 2018 | "next_offset": null, 2019 | "total_objects": 2 2020 | } 2021 | }` 2022 | 2023 | func TestGetTokensMultiple(t *testing.T) { 2024 | requests := []*http.Request{} 2025 | ts := httptest.NewTLSServer( 2026 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2027 | if len(requests) == 0 { 2028 | fmt.Fprintln(w, getTokensPage1Response) 2029 | } else { 2030 | fmt.Fprintln(w, getTokensPage2Response) 2031 | } 2032 | requests = append(requests, r) 2033 | }), 2034 | ) 2035 | defer ts.Close() 2036 | 2037 | duo := buildAdminClient(ts.URL, nil) 2038 | 2039 | result, err := duo.GetTokens() 2040 | 2041 | if len(requests) != 2 { 2042 | t.Errorf("Expected two requets, found %d", len(requests)) 2043 | } 2044 | 2045 | if len(result.Response) != 2 { 2046 | t.Errorf("Expected two tokens in the response, found %d", len(result.Response)) 2047 | } 2048 | 2049 | if err != nil { 2050 | t.Errorf("Expected err to be nil, found %s", err) 2051 | } 2052 | } 2053 | 2054 | func TestGetTokensPageArgs(t *testing.T) { 2055 | requests := []*http.Request{} 2056 | ts := httptest.NewTLSServer( 2057 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2058 | fmt.Fprintln(w, getEmptyPageArgsResponse) 2059 | requests = append(requests, r) 2060 | }), 2061 | ) 2062 | 2063 | defer ts.Close() 2064 | 2065 | duo := buildAdminClient(ts.URL, nil) 2066 | 2067 | _, err := duo.GetTokens(func(values *url.Values) { 2068 | values.Set("limit", "200") 2069 | values.Set("offset", "1") 2070 | return 2071 | }) 2072 | 2073 | if err != nil { 2074 | t.Errorf("Encountered unexpected error: %s", err) 2075 | } 2076 | 2077 | if len(requests) != 1 { 2078 | t.Errorf("Expected there to be one request, found %d", len(requests)) 2079 | } 2080 | request := requests[0] 2081 | request_query := request.URL.Query() 2082 | if request_query["limit"][0] != "200" { 2083 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 2084 | } 2085 | if request_query["offset"][0] != "1" { 2086 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 2087 | } 2088 | } 2089 | 2090 | const getTokenResponse = `{ 2091 | "stat": "OK", 2092 | "response": { 2093 | "serial": "0", 2094 | "token_id": "DHIZ34ALBA2445ND4AI2", 2095 | "type": "d1", 2096 | "totp_step": null, 2097 | "users": [{ 2098 | "user_id": "DUJZ2U4L80HT45MQ4EOQ", 2099 | "username": "jsmith", 2100 | "alias1": "joe.smith", 2101 | "alias2": "jsmith@example.com", 2102 | "realname": "Joe Smith", 2103 | "email": "jsmith@example.com", 2104 | "status": "active", 2105 | "last_login": 1343921403, 2106 | "notes": "" 2107 | }] 2108 | } 2109 | }` 2110 | 2111 | func TestGetToken(t *testing.T) { 2112 | ts := httptest.NewTLSServer( 2113 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2114 | fmt.Fprintln(w, getTokenResponse) 2115 | }), 2116 | ) 2117 | defer ts.Close() 2118 | 2119 | duo := buildAdminClient(ts.URL, nil) 2120 | 2121 | result, err := duo.GetToken("DPFZRS9FB0D46QFTM899") 2122 | if err != nil { 2123 | t.Errorf("Unexpected error from GetToken call %v", err.Error()) 2124 | } 2125 | if result.Stat != "OK" { 2126 | t.Errorf("Expected OK, but got %s", result.Stat) 2127 | } 2128 | if result.Response.TokenID != "DHIZ34ALBA2445ND4AI2" { 2129 | t.Errorf("Expected token ID DHIZ34ALBA2445ND4AI2, but got %s", result.Response.TokenID) 2130 | } 2131 | } 2132 | 2133 | const getU2FTokensResponse = `{ 2134 | "stat": "OK", 2135 | "response": [{ 2136 | "date_added": 1444678994, 2137 | "registration_id": "D21RU6X1B1DF5P54B6PV", 2138 | "user": { 2139 | "alias1": "joe.smith", 2140 | "alias2": "jsmith@example.com", 2141 | "alias3": null, 2142 | "alias4": null, 2143 | "created": 1384275337, 2144 | "email": "jsmith@example.com", 2145 | "firstname": "Joe", 2146 | "last_directory_sync": 1384275337, 2147 | "last_login": 1514922986, 2148 | "lastname": "Smith", 2149 | "notes": "", 2150 | "realname": "Joe Smith", 2151 | "status": "active", 2152 | "user_id": "DU3RP9I2WOC59VZX672N", 2153 | "username": "jsmith" 2154 | } 2155 | }], 2156 | "metadata": { 2157 | "prev_offset": null, 2158 | "next_offset": null, 2159 | "total_objects": 1 2160 | } 2161 | }` 2162 | 2163 | func TestGetU2FTokens(t *testing.T) { 2164 | var last_request *http.Request 2165 | ts := httptest.NewTLSServer( 2166 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2167 | fmt.Fprintln(w, getU2FTokensResponse) 2168 | last_request = r 2169 | }), 2170 | ) 2171 | defer ts.Close() 2172 | 2173 | duo := buildAdminClient(ts.URL, nil) 2174 | 2175 | result, err := duo.GetU2FTokens() 2176 | if err != nil { 2177 | t.Errorf("Unexpected error from GetU2FTokens call %v", err.Error()) 2178 | } 2179 | if result.Stat != "OK" { 2180 | t.Errorf("Expected OK, but got %s", result.Stat) 2181 | } 2182 | if len(result.Response) != 1 { 2183 | t.Errorf("Expected 1 token, but got %d", len(result.Response)) 2184 | } 2185 | if result.Response[0].RegistrationID != "D21RU6X1B1DF5P54B6PV" { 2186 | t.Errorf("Expected registration ID D21RU6X1B1DF5P54B6PV, but got %s", result.Response[0].RegistrationID) 2187 | } 2188 | 2189 | request_query := last_request.URL.Query() 2190 | if request_query["limit"][0] != "100" { 2191 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 2192 | } 2193 | if request_query["offset"][0] != "0" { 2194 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 2195 | } 2196 | } 2197 | 2198 | const getU2FTokensPage1Response = `{ 2199 | "stat": "OK", 2200 | "response": [{ 2201 | "date_added": 1444678994, 2202 | "registration_id": "D21RU6X1B1DF5P54B6PV", 2203 | "user": { 2204 | "alias1": "joe.smith", 2205 | "alias2": "jsmith@example.com", 2206 | "alias3": null, 2207 | "alias4": null, 2208 | "created": 1384275337, 2209 | "email": "jsmith@example.com", 2210 | "firstname": "Joe", 2211 | "last_directory_sync": 1384275337, 2212 | "last_login": 1514922986, 2213 | "lastname": "Smith", 2214 | "notes": "", 2215 | "realname": "Joe Smith", 2216 | "status": "active", 2217 | "user_id": "DU3RP9I2WOC59VZX672N", 2218 | "username": "jsmith" 2219 | } 2220 | }], 2221 | "metadata": { 2222 | "prev_offset": null, 2223 | "next_offset": 1, 2224 | "total_objects": 2 2225 | } 2226 | }` 2227 | 2228 | const getU2FTokensPage2Response = `{ 2229 | "stat": "OK", 2230 | "response": [{ 2231 | "date_added": 1444678994, 2232 | "registration_id": "D21RU6X1B1DF5P54B6PV", 2233 | "user": { 2234 | "alias1": "joe.smith", 2235 | "alias2": "jsmith@example.com", 2236 | "alias3": null, 2237 | "alias4": null, 2238 | "created": 1384275337, 2239 | "email": "jsmith@example.com", 2240 | "firstname": "Joe", 2241 | "last_directory_sync": 1384275337, 2242 | "last_login": 1514922986, 2243 | "lastname": "Smith", 2244 | "notes": "", 2245 | "realname": "Joe Smith", 2246 | "status": "active", 2247 | "user_id": "DU3RP9I2WOC59VZX672N", 2248 | "username": "jsmith" 2249 | } 2250 | }], 2251 | "metadata": { 2252 | "prev_offset": 0, 2253 | "next_offset": null, 2254 | "total_objects": 2 2255 | } 2256 | }` 2257 | 2258 | func TestGetU2fTokensMultiple(t *testing.T) { 2259 | requests := []*http.Request{} 2260 | ts := httptest.NewTLSServer( 2261 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2262 | if len(requests) == 0 { 2263 | fmt.Fprintln(w, getU2FTokensPage1Response) 2264 | } else { 2265 | fmt.Fprintln(w, getU2FTokensPage2Response) 2266 | } 2267 | requests = append(requests, r) 2268 | }), 2269 | ) 2270 | defer ts.Close() 2271 | 2272 | duo := buildAdminClient(ts.URL, nil) 2273 | 2274 | result, err := duo.GetU2FTokens() 2275 | 2276 | if len(requests) != 2 { 2277 | t.Errorf("Expected two requets, found %d", len(requests)) 2278 | } 2279 | 2280 | if len(result.Response) != 2 { 2281 | t.Errorf("Expected two tokens in the response, found %d", len(result.Response)) 2282 | } 2283 | 2284 | if err != nil { 2285 | t.Errorf("Expected err to be nil, found %s", err) 2286 | } 2287 | } 2288 | 2289 | func TestGetU2FTokensPageArgs(t *testing.T) { 2290 | requests := []*http.Request{} 2291 | ts := httptest.NewTLSServer( 2292 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2293 | fmt.Fprintln(w, getEmptyPageArgsResponse) 2294 | requests = append(requests, r) 2295 | }), 2296 | ) 2297 | 2298 | defer ts.Close() 2299 | 2300 | duo := buildAdminClient(ts.URL, nil) 2301 | 2302 | _, err := duo.GetU2FTokens(func(values *url.Values) { 2303 | values.Set("limit", "200") 2304 | values.Set("offset", "1") 2305 | return 2306 | }) 2307 | 2308 | if err != nil { 2309 | t.Errorf("Encountered unexpected error: %s", err) 2310 | } 2311 | 2312 | if len(requests) != 1 { 2313 | t.Errorf("Expected there to be one request, found %d", len(requests)) 2314 | } 2315 | request := requests[0] 2316 | request_query := request.URL.Query() 2317 | if request_query["limit"][0] != "200" { 2318 | t.Errorf("Expected to see a limit of 100 in request, bug got %s", request_query["limit"]) 2319 | } 2320 | if request_query["offset"][0] != "1" { 2321 | t.Errorf("Expected to see an offset of 0 in request, bug got %s", request_query["offset"]) 2322 | } 2323 | } 2324 | 2325 | func TestGetU2FToken(t *testing.T) { 2326 | ts := httptest.NewTLSServer( 2327 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2328 | fmt.Fprintln(w, getU2FTokensResponse) 2329 | }), 2330 | ) 2331 | defer ts.Close() 2332 | 2333 | duo := buildAdminClient(ts.URL, nil) 2334 | 2335 | result, err := duo.GetU2FToken("D21RU6X1B1DF5P54B6PV") 2336 | if err != nil { 2337 | t.Errorf("Unexpected error from GetU2FToken call %v", err.Error()) 2338 | } 2339 | if result.Stat != "OK" { 2340 | t.Errorf("Expected OK, but got %s", result.Stat) 2341 | } 2342 | if len(result.Response) != 1 { 2343 | t.Errorf("Expected 1 token, but got %d", len(result.Response)) 2344 | } 2345 | if result.Response[0].RegistrationID != "D21RU6X1B1DF5P54B6PV" { 2346 | t.Errorf("Expected registration ID D21RU6X1B1DF5P54B6PV, but got %s", result.Response[0].RegistrationID) 2347 | } 2348 | } 2349 | 2350 | const getBypassCodesResponse = `{ 2351 | "stat": "OK", 2352 | "response": [ 2353 | "407176182", 2354 | "016931781", 2355 | "338390347", 2356 | "537828175", 2357 | "006165274", 2358 | "438680449", 2359 | "877647224", 2360 | "196167433", 2361 | "719424708", 2362 | "727559878" 2363 | ] 2364 | }` 2365 | 2366 | func TestGetBypassCodes(t *testing.T) { 2367 | ts := httptest.NewTLSServer( 2368 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 2369 | fmt.Fprintln(w, getBypassCodesResponse) 2370 | }), 2371 | ) 2372 | defer ts.Close() 2373 | 2374 | duo := buildAdminClient(ts.URL, nil) 2375 | 2376 | result, err := duo.GetUserBypassCodes("D21RU6X1B1DF5P54B6PV") 2377 | 2378 | if err != nil { 2379 | t.Errorf("Unexpected error from GetUserBypassCodes call %v", err.Error()) 2380 | } 2381 | if result.Stat != "OK" { 2382 | t.Errorf("Expected OK, but got %s", result.Stat) 2383 | } 2384 | if len(result.Response) != 10 { 2385 | t.Errorf("Expected 10 codes, but got %d", len(result.Response)) 2386 | } 2387 | } 2388 | -------------------------------------------------------------------------------- /admin/logs.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | duoapi "github.com/duosecurity/duo_api_golang" 13 | ) 14 | 15 | // maxLogV1PageSize sets 1000 as the maximum page size for API V1 log endpoints. 16 | const maxLogV1PageSize = 1000 17 | 18 | /* 19 | * V2 Logs 20 | */ 21 | 22 | // LogListV2Metadata holds pagination metadata for API V2 log endpoints. 23 | type LogListV2Metadata struct { 24 | NextOffset []string `json:"next_offset"` 25 | } 26 | 27 | // GetNextOffset uses response metadata to return an option that will configure a request to fetch the next page of logs. It returns nil when no more logs can be fetched. 28 | func (metadata LogListV2Metadata) GetNextOffset() func(params *url.Values) { 29 | offset := strings.Join(metadata.NextOffset, ",") 30 | if offset == "" { 31 | return nil 32 | } 33 | return func(params *url.Values) { 34 | params.Set("next_offset", offset) 35 | } 36 | } 37 | 38 | // V2 Auth Logs 39 | 40 | // AuthLogResult is the structured JSON result of GetAuthLogs (for the V2 API). 41 | type AuthLogResult struct { 42 | duoapi.StatResult 43 | Response AuthLogList `json:"response"` 44 | } 45 | 46 | // An AuthLog retrieved from https://duo.com/docs/adminapi#authentication-logs 47 | // TODO: @Duo update this to be a struct based on the returned JSON structure of an authentication log. 48 | type AuthLog map[string]interface{} 49 | 50 | // An AuthLogList holds retreived logs and V2 metadata used for pagination. 51 | type AuthLogList struct { 52 | Metadata LogListV2Metadata `json:"metadata"` 53 | Logs []AuthLog `json:"authlogs"` 54 | } 55 | 56 | // GetAuthLogs retrieves a page of authentication logs within the time range starting at mintime and ending at mintime + window. It relies on the option provided by AuthLogResult.Metadata.GetNextOffset() for pagination. 57 | // Calls GET /admin/v2/logs/authentication 58 | // See https://duo.com/docs/adminapi#authentication-logs 59 | func (c *Client) GetAuthLogs(mintime time.Time, window time.Duration, options ...func(*url.Values)) (*AuthLogResult, error) { 60 | // Format mintime & maxtime parameters 61 | minMs := mintime.UnixNano() / int64(time.Millisecond) 62 | maxMs := mintime.Add(window).UnixNano() / int64(time.Millisecond) 63 | mintimeStr := strconv.FormatInt(minMs, 10) 64 | maxtimeStr := strconv.FormatInt(maxMs, 10) 65 | 66 | // Request defaults 67 | params := url.Values{ 68 | "mintime": []string{mintimeStr}, 69 | "maxtime": []string{maxtimeStr}, 70 | } 71 | 72 | // Configure request with additional options 73 | for _, opt := range options { 74 | opt(¶ms) 75 | } 76 | 77 | // Retrieve page of authentication logs 78 | resp, body, err := c.SignedCall( 79 | http.MethodGet, 80 | "/admin/v2/logs/authentication", 81 | params, 82 | ) 83 | if err != nil { 84 | return nil, err 85 | } 86 | if resp.StatusCode != http.StatusOK { 87 | return nil, fmt.Errorf("invalid HTTP response code from Duo API: [%d] %s", resp.StatusCode, resp.Status) 88 | } 89 | 90 | // Unmarshal received JSON into expected structure 91 | result := &AuthLogResult{} 92 | if err = json.Unmarshal(body, result); err != nil { 93 | return nil, err 94 | } 95 | 96 | return result, nil 97 | } 98 | 99 | /* 100 | * V1 Logs 101 | */ 102 | 103 | // parseLogV1Timestamp attempts to coerce the timestamp field of a log into a time.Time 104 | func parseLogV1Timestamp(log map[string]interface{}) (time.Time, error) { 105 | var timestamp time.Time 106 | 107 | // Skip nil logs 108 | if log == nil { 109 | return timestamp, fmt.Errorf("cannot determine timestamp of nil log") 110 | } 111 | 112 | // Skip logs without a timestamp 113 | untypedTimestamp, ok := log["timestamp"] 114 | if !ok || untypedTimestamp == nil { 115 | return timestamp, fmt.Errorf("failed to parse value for timestamp field from log data") 116 | } 117 | 118 | // Skip logs with an invalid timestamp format 119 | switch num := untypedTimestamp.(type) { 120 | case float64: 121 | timestamp = time.Unix(int64(num), 0) 122 | break 123 | case int: 124 | timestamp = time.Unix(int64(num), 0) 125 | break 126 | case int32: 127 | timestamp = time.Unix(int64(num), 0) 128 | break 129 | case int64: 130 | timestamp = time.Unix(num, 0) 131 | break 132 | default: 133 | return timestamp, fmt.Errorf("received non-integer value in parsed timestamp field from log data") 134 | } 135 | 136 | // Skip logs with zero value timestamp 137 | if timestamp.IsZero() { 138 | return timestamp, fmt.Errorf("timestamp parsed from log data is zero") 139 | } 140 | 141 | return timestamp, nil 142 | } 143 | 144 | // getLogListV1NextOffset provides an option for pagination based on log timestamps. It returns nil when no more logs can be fetched. 145 | func getLogListV1NextOffset(end time.Time, timestamps ...time.Time) func(params *url.Values) { 146 | // Receiving less than a full page indicates there are no more pages to fetch. 147 | if len(timestamps) < maxLogV1PageSize { 148 | return nil 149 | } 150 | 151 | // Determine min and max timestamps 152 | var max time.Time 153 | var min time.Time 154 | 155 | for _, timestamp := range timestamps { 156 | // Skip logs with zero value for timestamp 157 | if timestamp.IsZero() { 158 | continue 159 | } 160 | 161 | // We've collected a superset of logs for the time range, no next fetch 162 | if timestamp.After(end) { 163 | return nil 164 | } 165 | 166 | // Track maximum timestamp 167 | if timestamp.After(max) { 168 | max = timestamp 169 | } 170 | 171 | // Track minimum timestamp 172 | if min.IsZero() || timestamp.Before(min) { 173 | min = timestamp 174 | } 175 | } 176 | 177 | // Next mintime should be the maximum timestamp of the collected logs 178 | next := max 179 | 180 | // Entire page has same timestamp, increment to avoid infinitely fetching logs 181 | if min.Equal(max) { 182 | next = next.Add(1 * time.Second) 183 | } 184 | 185 | // A next mintime of zero means there is no next timestamp to fetch 186 | if next.IsZero() { 187 | return nil 188 | } 189 | 190 | // Configures the mintime parameter of the next request that is the maximum timestamp of received logs (in seconds). 191 | return func(params *url.Values) { 192 | params.Set("mintime", fmt.Sprintf("%d", next.Unix())) 193 | } 194 | } 195 | 196 | // V1 Admin Logs 197 | 198 | // AdminLogResult is the structured JSON result of GetAdminLogs (for the V1 API). 199 | type AdminLogResult struct { 200 | duoapi.StatResult 201 | Logs AdminLogList `json:"response"` 202 | } 203 | 204 | // An AdminLog retrieved from https://duo.com/docs/adminapi#administrator-logs 205 | // TODO: @Duo update this to be a struct based on the returned JSON structure of an admin log. 206 | type AdminLog map[string]interface{} 207 | 208 | // Timestamp parses and coerces the timestamp value of the log. 209 | func (log AdminLog) Timestamp() (time.Time, error) { 210 | return parseLogV1Timestamp(log) 211 | } 212 | 213 | // An AdminLogList holds log entries and provides functionality used for pagination. 214 | type AdminLogList []AdminLog 215 | 216 | // GetNextOffset uses log timestamps to return an option that will configure a request to fetch the next page of logs. It returns nil when no more logs can be fetched. 217 | func (logs AdminLogList) GetNextOffset(maxtime time.Time) func(params *url.Values) { 218 | // Receiving less than a full page indicates there are no more pages to fetch. 219 | if len(logs) < maxLogV1PageSize { 220 | return nil 221 | } 222 | 223 | // Gather log timestamps 224 | timestamps := make([]time.Time, 0, len(logs)) 225 | for _, log := range logs { 226 | ts, err := log.Timestamp() 227 | if err != nil { 228 | continue 229 | } 230 | timestamps = append(timestamps, ts) 231 | } 232 | 233 | return getLogListV1NextOffset(maxtime, timestamps...) 234 | } 235 | 236 | // GetAdminLogs retrieves a page of admin logs with timestamps starting at mintime. It relies on the option provided by AdminLogResult.Logs.GetNextOffset() for pagination. 237 | // Calls GET /admin/v1/logs/administrator 238 | // See https://duo.com/docs/adminapi#administrator-logs 239 | func (c *Client) GetAdminLogs(mintime time.Time, options ...func(*url.Values)) (*AdminLogResult, error) { 240 | // Format mintime parameter 241 | min := mintime.UnixNano() / int64(time.Second) 242 | mintimeStr := strconv.FormatInt(min, 10) 243 | 244 | // Request defaults 245 | params := url.Values{ 246 | "mintime": []string{mintimeStr}, 247 | } 248 | 249 | // Configure request with additional options 250 | for _, opt := range options { 251 | opt(¶ms) 252 | } 253 | 254 | // Retrieve page of admin logs 255 | resp, body, err := c.SignedCall( 256 | http.MethodGet, 257 | "/admin/v1/logs/administrator", 258 | params, 259 | ) 260 | if err != nil { 261 | return nil, err 262 | } 263 | if resp.StatusCode != http.StatusOK { 264 | return nil, fmt.Errorf("invalid HTTP response code from Duo API: [%d] %s", resp.StatusCode, resp.Status) 265 | } 266 | 267 | // Unmarshal received JSON into expected structure 268 | result := &AdminLogResult{} 269 | if err = json.Unmarshal(body, result); err != nil { 270 | return nil, err 271 | } 272 | 273 | return result, nil 274 | } 275 | 276 | // V1 Telephony Logs 277 | 278 | // TelephonyLogResult is the structured JSON result of GetTelephonyLogs (for the V1 API). 279 | type TelephonyLogResult struct { 280 | duoapi.StatResult 281 | Logs TelephonyLogList `json:"response"` 282 | } 283 | 284 | // A TelephonyLog retrieved from https://duo.com/docs/adminapi#telephony-logs 285 | // TODO: @Duo update this to be a struct based on the returned JSON structure of a telephony log. 286 | type TelephonyLog map[string]interface{} 287 | 288 | // Timestamp parses and coerces the timestamp value of the log. 289 | func (log TelephonyLog) Timestamp() (time.Time, error) { 290 | return parseLogV1Timestamp(log) 291 | } 292 | 293 | // An TelephonyLogList holds log entries and provides functionality used for pagination. 294 | type TelephonyLogList []TelephonyLog 295 | 296 | // GetNextOffset uses log timestamps to return an option that will configure a request to fetch the next page of logs. It returns nil when no more logs can be fetched. 297 | func (logs TelephonyLogList) GetNextOffset(maxtime time.Time) func(params *url.Values) { 298 | // Receiving less than a full page indicates there are no more pages to fetch. 299 | if len(logs) < maxLogV1PageSize { 300 | return nil 301 | } 302 | 303 | // Gather log timestamps 304 | timestamps := make([]time.Time, 0, len(logs)) 305 | for _, log := range logs { 306 | ts, err := log.Timestamp() 307 | if err != nil { 308 | continue 309 | } 310 | timestamps = append(timestamps, ts) 311 | } 312 | 313 | return getLogListV1NextOffset(maxtime, timestamps...) 314 | } 315 | 316 | // GetTelephonyLogs retrieves a page of telephony logs with timestamps starting at mintime. It relies on the option provided by TelephonyLogResult.Logs.GetNextOffset() for pagination. 317 | // Calls GET /admin/v1/logs/telephony 318 | // See https://duo.com/docs/adminapi#telephony-logs 319 | func (c *Client) GetTelephonyLogs(mintime time.Time, options ...func(*url.Values)) (*TelephonyLogResult, error) { 320 | // Format mintime parameter 321 | min := mintime.UnixNano() / int64(time.Second) 322 | mintimeStr := strconv.FormatInt(min, 10) 323 | 324 | // Request defaults 325 | params := url.Values{ 326 | "mintime": []string{mintimeStr}, 327 | } 328 | 329 | // Configure request with additional options 330 | for _, opt := range options { 331 | opt(¶ms) 332 | } 333 | 334 | // Retrieve page of telephony logs 335 | resp, body, err := c.SignedCall( 336 | http.MethodGet, 337 | "/admin/v1/logs/telephony", 338 | params, 339 | ) 340 | if err != nil { 341 | return nil, err 342 | } 343 | if resp.StatusCode != http.StatusOK { 344 | return nil, fmt.Errorf("invalid HTTP response code from Duo API: [%d] %s", resp.StatusCode, resp.Status) 345 | } 346 | 347 | // Unmarshal received JSON into expected structure 348 | result := &TelephonyLogResult{} 349 | if err = json.Unmarshal(body, result); err != nil { 350 | return nil, err 351 | } 352 | 353 | return result, nil 354 | } 355 | -------------------------------------------------------------------------------- /admin/logs_test.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | /* 13 | * Logs 14 | */ 15 | 16 | // TestLogListV2Metadata ensures that the next offset is properly configured based on known metadata. 17 | func TestLogListV2Metadata(t *testing.T) { 18 | page := LogListV2Metadata{ 19 | NextOffset: []string{"1532951895000", "af0ba235-0b33-23c8-bc23-a31aa0231de8"}, 20 | } 21 | nextOffset := page.GetNextOffset() 22 | if nextOffset == nil { 23 | t.Fatalf("Expected option to configure next offset, got nil") 24 | } 25 | params := url.Values{} 26 | nextOffset(¶ms) 27 | if nextParam := params.Get("next_offset"); nextParam != "1532951895000,af0ba235-0b33-23c8-bc23-a31aa0231de8" { 28 | t.Fatalf("Expected option to configure next offset to be '1532951895000,af0ba235-0b33-23c8-bc23-a31aa0231de8', got %q", nextParam) 29 | } 30 | 31 | lastPage := LogListV2Metadata{} 32 | if lastPage.GetNextOffset() != nil { 33 | t.Errorf("Expected nil option to represent no more available logs, got a non-nil option") 34 | } 35 | } 36 | 37 | // TestLogListV2Metadata ensures that timestamps are properly parsed from log data. 38 | func TestParseLogV1Timestamp(t *testing.T) { 39 | validLog := map[string]interface{}{ 40 | "timestamp": 1346172820, 41 | } 42 | timestamp, err := parseLogV1Timestamp(validLog) 43 | if err != nil { 44 | t.Errorf("Failed to parse log timestamp: %v", err) 45 | } 46 | if expectedTs := time.Unix(1346172820, 0); !timestamp.Equal(expectedTs) { 47 | t.Errorf("Parsed incorrect value for log timestamp, expected %v but got: %v", expectedTs, timestamp) 48 | } 49 | } 50 | 51 | // getAuthLogsResponse is an example response from the Duo API documentation example: https://duo.com/docs/adminapi#authentication-logs 52 | const getAuthLogsResponse = `{ 53 | "response": { 54 | "authlogs": [ 55 | { 56 | "access_device": { 57 | "browser": "Chrome", 58 | "browser_version": "67.0.3396.99", 59 | "flash_version": "uninstalled", 60 | "hostname": "null", 61 | "ip": "169.232.89.219", 62 | "java_version": "uninstalled", 63 | "location": { 64 | "city": "Ann Arbor", 65 | "country": "United States", 66 | "state": "Michigan" 67 | }, 68 | "os": "Mac OS X", 69 | "os_version": "10.14.1" 70 | }, 71 | "application": { 72 | "key": "DIY231J8BR23QK4UKBY8", 73 | "name": "Microsoft Azure Active Directory" 74 | }, 75 | "auth_device": { 76 | "ip": "192.168.225.254", 77 | "location": { 78 | "city": "Ann Arbor", 79 | "country": "United States", 80 | "state": "Michigan" 81 | }, 82 | "name": "My iPhone X (734-555-2342)" 83 | }, 84 | "event_type": "authentication", 85 | "factor": "duo_push", 86 | "reason": "user_approved", 87 | "result": "success", 88 | "timestamp": 1532951962, 89 | "trusted_endpoint_status": "not trusted", 90 | "txid": "340a23e3-23f3-23c1-87dc-1491a23dfdbb", 91 | "user": { 92 | "key": "DU3KC77WJ06Y5HIV7XKQ", 93 | "name": "narroway@example.com" 94 | } 95 | } 96 | ], 97 | "metadata": { 98 | "next_offset": [ 99 | "1532951895000", 100 | "af0ba235-0b33-23c8-bc23-a31aa0231de8" 101 | ], 102 | "total_objects": 1 103 | } 104 | }, 105 | "stat": "OK" 106 | }` 107 | 108 | // TestGetAuthLogs ensures proper functionality of the client.GetAuthLogs method. 109 | func TestGetAuthLogs(t *testing.T) { 110 | var last_request *http.Request 111 | ts := httptest.NewTLSServer( 112 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 | fmt.Fprintln(w, getAuthLogsResponse) 114 | last_request = r 115 | }), 116 | ) 117 | defer ts.Close() 118 | 119 | duo := buildAdminClient(ts.URL, nil) 120 | 121 | lastMetadata := LogListV2Metadata{ 122 | NextOffset: []string{"1532951920000", "b40ba235-0b33-23c8-bc23-a31aa0231db4"}, 123 | } 124 | mintime := time.Unix(1532951960, 0) 125 | window := 5 * time.Second 126 | result, err := duo.GetAuthLogs(mintime, window, lastMetadata.GetNextOffset()) 127 | 128 | if err != nil { 129 | t.Errorf("Unexpected error from GetAuthLogs call: %v", err.Error()) 130 | } 131 | if result.Stat != "OK" { 132 | t.Errorf("Expected OK, but got %s", result.Stat) 133 | } 134 | if length := len(result.Response.Logs); length != 1 { 135 | t.Errorf("Expected 1 log, but got %d", length) 136 | } 137 | if txid := result.Response.Logs[0]["txid"]; txid != "340a23e3-23f3-23c1-87dc-1491a23dfdbb" { 138 | t.Errorf("Expected txid '340a23e3-23f3-23c1-87dc-1491a23dfdbb', but got %v", txid) 139 | } 140 | if next := result.Response.Metadata.GetNextOffset(); next == nil { 141 | t.Errorf("Expected metadata.GetNextOffset option to configure pagination for next request, got nil") 142 | } 143 | 144 | request_query := last_request.URL.Query() 145 | if qMintime := request_query["mintime"][0]; qMintime != "1532951960000" { 146 | t.Errorf("Expected to see a mintime of 153295196000 in request, but got %q", qMintime) 147 | } 148 | if qMaxtime := request_query["maxtime"][0]; qMaxtime != "1532951965000" { 149 | t.Errorf("Expected to see a maxtime of 153295196500 in request, but got %q", qMaxtime) 150 | } 151 | if qNextOffset := request_query["next_offset"][0]; qNextOffset != "1532951920000,b40ba235-0b33-23c8-bc23-a31aa0231db4" { 152 | t.Errorf("Expected to see a next_offset of 1532951920000,b40ba235-0b33-23c8-bc23-a31aa0231db4 in request, but got %q", qNextOffset) 153 | } 154 | } 155 | 156 | // getAdminLogsResponse is an example response from the Duo API documentation example: https://duo.com/docs/adminapi#administrator-logs 157 | const getAdminLogsResponse = `{ 158 | "stat": "OK", 159 | "response": [{ 160 | "action": "user_update", 161 | "description": "{\"notes\": \"Joe asked for their nickname to be displayed instead of Joseph.\", \"realname\": \"Joe Smith\"}", 162 | "object": "jsmith", 163 | "timestamp": 1346172820, 164 | "username": "admin" 165 | }, 166 | { 167 | "action": "admin_login_error", 168 | "description": "{\"ip_address\": \"10.1.23.116\", \"error\": \"SAML login is disabled\", \"email\": \"narroway@example.com\"}", 169 | "object": null, 170 | "timestamp": 1446172820, 171 | "username": "" 172 | }] 173 | }` 174 | 175 | // TestGetAdminLogs ensures proper functionality of the client.GetAdminLogs method. 176 | func TestGetAdminLogs(t *testing.T) { 177 | var last_request *http.Request 178 | ts := httptest.NewTLSServer( 179 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 180 | fmt.Fprintln(w, getAdminLogsResponse) 181 | last_request = r 182 | }), 183 | ) 184 | defer ts.Close() 185 | 186 | duo := buildAdminClient(ts.URL, nil) 187 | 188 | mintime := time.Unix(1346172815, 0) 189 | maxtime := mintime.Add(time.Second * 10) 190 | result, err := duo.GetAdminLogs(mintime) 191 | 192 | if err != nil { 193 | t.Errorf("Unexpected error from GetAdminLogs call: %v", err.Error()) 194 | } 195 | if result.Stat != "OK" { 196 | t.Errorf("Expected OK, but got %s", result.Stat) 197 | } 198 | if length := len(result.Logs); length != 2 { 199 | t.Errorf("Expected 2 logs, but got %d", length) 200 | } 201 | timestamp, err := result.Logs[0].Timestamp() 202 | if err != nil { 203 | t.Errorf("Failed to parse timestamp timestamp: %v", err) 204 | } 205 | if expectedTs := time.Unix(1346172820, 0); !expectedTs.Equal(timestamp) { 206 | t.Errorf("Expected timestamp %v, but got: %v", expectedTs, timestamp) 207 | } 208 | if next := result.Logs.GetNextOffset(maxtime); next != nil { 209 | t.Errorf("Expected no next page available, got non-nil option") 210 | } 211 | 212 | request_query := last_request.URL.Query() 213 | if qMintime := request_query["mintime"][0]; qMintime != "1346172815" { 214 | t.Errorf("Expected to see a mintime of 1346172815 in request, but got %q", qMintime) 215 | } 216 | } 217 | 218 | // TestAdminLogsNextOffset ensures proper pagination functionality for AdminLogResult 219 | func TestAdminLogsNextOffset(t *testing.T) { 220 | maxtime := time.Unix(1346172825, 0) 221 | 222 | // Ensure < 1000 logs returns none 223 | result := &AdminLogResult{} 224 | if next := result.Logs.GetNextOffset(maxtime); next != nil { 225 | t.Errorf("Expected no next page available, got non-nil option") 226 | } 227 | 228 | // Ensure mintime == maxtime returns maxtime + 1 229 | logs := make([]AdminLog, 0, 1000) 230 | for i := 0; i < 1000; i++ { 231 | logs = append(logs, AdminLog{"timestamp": 1346172816}) 232 | } 233 | result.Logs = AdminLogList(logs) 234 | params := &url.Values{} 235 | result.Logs.GetNextOffset(maxtime)(params) 236 | if newMintime := params.Get("mintime"); newMintime != "1346172817" { 237 | t.Errorf("Expected new mintime to be 1346172817, got: %v", newMintime) 238 | } 239 | 240 | // Ensure single maxtime returns maxtime 241 | result.Logs[0] = AdminLog{"timestamp": 1346172820} 242 | params = &url.Values{} 243 | result.Logs.GetNextOffset(maxtime)(params) 244 | if newMintime := params.Get("mintime"); newMintime != "1346172820" { 245 | t.Errorf("Expected new mintime to be 1346172820, got: %v", newMintime) 246 | } 247 | } 248 | 249 | // getTelephonyLogsResponse is an example response from the Duo API documentation example: https://duo.com/docs/adminapi#telephony-logs 250 | const getTelephonyLogsResponse = `{ 251 | "stat": "OK", 252 | "response": [{ 253 | "context": "authentication", 254 | "credits": 1, 255 | "phone": "+15035550100", 256 | "timestamp": 1346172697, 257 | "type": "sms" 258 | }] 259 | }` 260 | 261 | // TestGetTelephonyLogs ensures proper functionality of the client.GetTelephonyLogs method. 262 | func TestGetTelephonyLogs(t *testing.T) { 263 | var last_request *http.Request 264 | ts := httptest.NewTLSServer( 265 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 266 | fmt.Fprintln(w, getTelephonyLogsResponse) 267 | last_request = r 268 | }), 269 | ) 270 | defer ts.Close() 271 | 272 | duo := buildAdminClient(ts.URL, nil) 273 | 274 | mintime := time.Unix(1346172600, 0) 275 | maxtime := mintime.Add(time.Second * 10) 276 | result, err := duo.GetTelephonyLogs(mintime) 277 | 278 | if err != nil { 279 | t.Errorf("Unexpected error from GetTelephonyLogs call: %v", err.Error()) 280 | } 281 | if result.Stat != "OK" { 282 | t.Errorf("Expected OK, but got %s", result.Stat) 283 | } 284 | if length := len(result.Logs); length != 1 { 285 | t.Errorf("Expected 1 logs, but got %d", length) 286 | } 287 | timestamp, err := result.Logs[0].Timestamp() 288 | if err != nil { 289 | t.Errorf("Failed to parse timestamp timestamp: %v", err) 290 | } 291 | if expectedTs := time.Unix(1346172697, 0); !expectedTs.Equal(timestamp) { 292 | t.Errorf("Expected timestamp %v, but got: %v", expectedTs, timestamp) 293 | } 294 | if next := result.Logs.GetNextOffset(maxtime); next != nil { 295 | t.Errorf("Expected no next page available, got non-nil option") 296 | } 297 | 298 | request_query := last_request.URL.Query() 299 | if qMintime := request_query["mintime"][0]; qMintime != "1346172600" { 300 | t.Errorf("Expected to see a mintime of 1346172600 in request, but got %q", qMintime) 301 | } 302 | } 303 | 304 | // TestTelephonyLogsNextOffset ensures proper pagination functionality for TelephonyLogResult 305 | func TestTelephonyLogsNextOffset(t *testing.T) { 306 | maxtime := time.Unix(1346172825, 0) 307 | 308 | // Ensure < 1000 logs returns none 309 | result := &TelephonyLogResult{} 310 | if next := result.Logs.GetNextOffset(maxtime); next != nil { 311 | t.Errorf("Expected no next page available, got non-nil option") 312 | } 313 | 314 | // Ensure mintime == maxtime returns maxtime + 1 315 | logs := make([]TelephonyLog, 0, 1000) 316 | for i := 0; i < 1000; i++ { 317 | logs = append(logs, TelephonyLog{"timestamp": 1346172816}) 318 | } 319 | result.Logs = TelephonyLogList(logs) 320 | params := &url.Values{} 321 | result.Logs.GetNextOffset(maxtime)(params) 322 | if newMintime := params.Get("mintime"); newMintime != "1346172817" { 323 | t.Errorf("Expected new mintime to be 1346172817, got: %v", newMintime) 324 | } 325 | 326 | // Ensure single maxtime returns maxtime 327 | result.Logs[0] = TelephonyLog{"timestamp": 1346172820} 328 | params = &url.Values{} 329 | result.Logs.GetNextOffset(maxtime)(params) 330 | if newMintime := params.Get("mintime"); newMintime != "1346172820" { 331 | t.Errorf("Expected new mintime to be 1346172820, got: %v", newMintime) 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /authapi/authapi.go: -------------------------------------------------------------------------------- 1 | package authapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | "strconv" 7 | 8 | duoapi "github.com/duosecurity/duo_api_golang" 9 | ) 10 | 11 | type AuthApi struct { 12 | duoapi.DuoApi 13 | } 14 | 15 | // Build a new Duo Auth API object. 16 | // api is a duoapi.DuoApi object used to make the Duo Rest API calls. 17 | // Example: authapi.NewAuthApi(*duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second))) 18 | func NewAuthApi(api duoapi.DuoApi) *AuthApi { 19 | return &AuthApi{api} 20 | } 21 | 22 | // Leaving for backwards compatibility. 23 | // The struct in use has been moved to the duoapi package, to be shared between the admin and authapi packages. 24 | type StatResult struct { 25 | Stat string 26 | Code *int32 27 | Message *string 28 | Message_Detail *string 29 | } 30 | 31 | // Return object for the 'Ping' API call. 32 | type PingResult struct { 33 | duoapi.StatResult 34 | Response struct { 35 | Time int64 36 | } 37 | } 38 | 39 | // Duo's Ping method. https://www.duosecurity.com/docs/authapi#/ping 40 | // This is an unsigned Duo Rest API call which returns the Duo system's time. 41 | // Use this method to determine whether your system time is in sync with Duo's. 42 | func (api *AuthApi) Ping() (*PingResult, error) { 43 | _, body, err := api.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout) 44 | if err != nil { 45 | return nil, err 46 | } 47 | ret := &PingResult{} 48 | if err = json.Unmarshal(body, ret); err != nil { 49 | return nil, err 50 | } 51 | ret.SyncCode() 52 | return ret, nil 53 | } 54 | 55 | // Return object for the 'Check' API call. 56 | type CheckResult struct { 57 | duoapi.StatResult 58 | Response struct { 59 | Time int64 60 | } 61 | } 62 | 63 | // Call Duo's Check method. https://www.duosecurity.com/docs/authapi#/check 64 | // Check is a signed Duo API call, which returns the Duo system's time. 65 | // Use this method to determine whether your ikey, skey and host are correct, 66 | // and whether your system time is in sync with Duo's. 67 | func (api *AuthApi) Check() (*CheckResult, error) { 68 | _, body, err := api.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout) 69 | if err != nil { 70 | return nil, err 71 | } 72 | ret := &CheckResult{} 73 | if err = json.Unmarshal(body, ret); err != nil { 74 | return nil, err 75 | } 76 | ret.SyncCode() 77 | return ret, nil 78 | } 79 | 80 | // Return object for the 'Logo' API call. 81 | type LogoResult struct { 82 | duoapi.StatResult 83 | png *[]byte 84 | } 85 | 86 | // Duo's Logo method. https://www.duosecurity.com/docs/authapi#/logo 87 | // If the API call is successful, the configured logo png is returned. Othwerwise, 88 | // error information is returned in the LogoResult return value. 89 | func (api *AuthApi) Logo() (*LogoResult, error) { 90 | resp, body, err := api.SignedCall("GET", "/auth/v2/logo", nil, duoapi.UseTimeout) 91 | if err != nil { 92 | return nil, err 93 | } 94 | if resp.StatusCode == 200 { 95 | ret := &LogoResult{StatResult: duoapi.StatResult{Stat: "OK"}, 96 | png: &body} 97 | return ret, nil 98 | } 99 | ret := &LogoResult{} 100 | if err = json.Unmarshal(body, ret); err != nil { 101 | return nil, err 102 | } 103 | ret.SyncCode() 104 | return ret, nil 105 | } 106 | 107 | // Optional parameter for the Enroll method. 108 | func EnrollUsername(username string) func(*url.Values) { 109 | return func(opts *url.Values) { 110 | opts.Set("username", username) 111 | } 112 | } 113 | 114 | // Optional parameter for the Enroll method. 115 | func EnrollValidSeconds(secs uint64) func(*url.Values) { 116 | return func(opts *url.Values) { 117 | opts.Set("valid_secs", strconv.FormatUint(secs, 10)) 118 | } 119 | } 120 | 121 | // Enroll return type. 122 | type EnrollResult struct { 123 | duoapi.StatResult 124 | Response struct { 125 | Activation_Barcode string 126 | Activation_Code string 127 | Expiration int64 128 | User_Id string 129 | Username string 130 | } 131 | } 132 | 133 | // Duo's Enroll method. https://www.duosecurity.com/docs/authapi#/enroll 134 | // Use EnrollUsername() to include the optional username parameter. 135 | // Use EnrollValidSeconds() to change the default validation time limit that the 136 | // user has to complete enrollment. 137 | func (api *AuthApi) Enroll(options ...func(*url.Values)) (*EnrollResult, error) { 138 | opts := url.Values{} 139 | for _, o := range options { 140 | o(&opts) 141 | } 142 | 143 | _, body, err := api.SignedCall("POST", "/auth/v2/enroll", opts, duoapi.UseTimeout) 144 | if err != nil { 145 | return nil, err 146 | } 147 | ret := &EnrollResult{} 148 | if err = json.Unmarshal(body, ret); err != nil { 149 | return nil, err 150 | } 151 | ret.SyncCode() 152 | return ret, nil 153 | } 154 | 155 | // Response is "success", "invalid" or "waiting". 156 | type EnrollStatusResult struct { 157 | duoapi.StatResult 158 | Response string 159 | } 160 | 161 | // Duo's EnrollStatus method. https://www.duosecurity.com/docs/authapi#/enroll_status 162 | // Return the status of an outstanding Enrollment. 163 | func (api *AuthApi) EnrollStatus(userid string, 164 | activationCode string) (*EnrollStatusResult, error) { 165 | queryArgs := url.Values{} 166 | queryArgs.Set("user_id", userid) 167 | queryArgs.Set("activation_code", activationCode) 168 | 169 | _, body, err := api.SignedCall("POST", 170 | "/auth/v2/enroll_status", 171 | queryArgs, 172 | duoapi.UseTimeout) 173 | 174 | if err != nil { 175 | return nil, err 176 | } 177 | ret := &EnrollStatusResult{} 178 | if err = json.Unmarshal(body, ret); err != nil { 179 | return nil, err 180 | } 181 | ret.SyncCode() 182 | return ret, nil 183 | } 184 | 185 | // Preauth return type. 186 | type PreauthResult struct { 187 | duoapi.StatResult 188 | Response struct { 189 | Result string 190 | Status_Msg string 191 | Enroll_Portal_Url string 192 | Devices []struct { 193 | Device string 194 | Type string 195 | Name string 196 | Number string 197 | Capabilities []string 198 | } 199 | } 200 | } 201 | 202 | func PreauthUserId(userid string) func(*url.Values) { 203 | return func(opts *url.Values) { 204 | opts.Set("user_id", userid) 205 | } 206 | } 207 | 208 | func PreauthUsername(username string) func(*url.Values) { 209 | return func(opts *url.Values) { 210 | opts.Set("username", username) 211 | } 212 | } 213 | 214 | func PreauthIpAddr(ip string) func(*url.Values) { 215 | return func(opts *url.Values) { 216 | opts.Set("ipaddr", ip) 217 | } 218 | } 219 | 220 | func PreauthTrustedToken(trustedtoken string) func(*url.Values) { 221 | return func(opts *url.Values) { 222 | opts.Set("trusted_device_token", trustedtoken) 223 | } 224 | } 225 | 226 | // Duo's Preauth method. https://www.duosecurity.com/docs/authapi#/preauth 227 | // options Optional values to include in the preauth call. 228 | // Use PreauthUserId to specify the user_id parameter. 229 | // Use PreauthUsername to specify the username parameter. You must 230 | // specify PreauthUserId or PreauthUsername, but not both. 231 | // Use PreauthIpAddr to include the ipaddr parameter, the ip address 232 | // of the client attempting authroization. 233 | // Use PreauthTrustedToken to specify the trusted_device_token parameter. 234 | func (api *AuthApi) Preauth(options ...func(*url.Values)) (*PreauthResult, error) { 235 | opts := url.Values{} 236 | for _, o := range options { 237 | o(&opts) 238 | } 239 | _, body, err := api.SignedCall("POST", "/auth/v2/preauth", opts, duoapi.UseTimeout) 240 | if err != nil { 241 | return nil, err 242 | } 243 | ret := &PreauthResult{} 244 | if err = json.Unmarshal(body, ret); err != nil { 245 | return nil, err 246 | } 247 | ret.SyncCode() 248 | return ret, nil 249 | } 250 | 251 | func AuthUserId(userid string) func(*url.Values) { 252 | return func(opts *url.Values) { 253 | opts.Set("user_id", userid) 254 | } 255 | } 256 | 257 | func AuthUsername(username string) func(*url.Values) { 258 | return func(opts *url.Values) { 259 | opts.Set("username", username) 260 | } 261 | } 262 | 263 | func AuthIpAddr(ip string) func(*url.Values) { 264 | return func(opts *url.Values) { 265 | opts.Set("ipaddr", ip) 266 | } 267 | } 268 | 269 | func AuthAsync() func(*url.Values) { 270 | return func(opts *url.Values) { 271 | opts.Set("async", "1") 272 | } 273 | } 274 | 275 | func AuthDevice(device string) func(*url.Values) { 276 | return func(opts *url.Values) { 277 | opts.Set("device", device) 278 | } 279 | } 280 | 281 | func AuthType(type_ string) func(*url.Values) { 282 | return func(opts *url.Values) { 283 | opts.Set("type", type_) 284 | } 285 | } 286 | 287 | func AuthDisplayUsername(username string) func(*url.Values) { 288 | return func(opts *url.Values) { 289 | opts.Set("display_username", username) 290 | } 291 | } 292 | 293 | func AuthPushinfo(pushinfo string) func(*url.Values) { 294 | return func(opts *url.Values) { 295 | opts.Set("pushinfo", pushinfo) 296 | } 297 | } 298 | 299 | func AuthPasscode(passcode string) func(*url.Values) { 300 | return func(opts *url.Values) { 301 | opts.Set("passcode", passcode) 302 | } 303 | } 304 | 305 | // Auth return type. 306 | type AuthResult struct { 307 | duoapi.StatResult 308 | Response struct { 309 | // Synchronous 310 | Result string 311 | Status string 312 | Status_Msg string 313 | Trusted_Device_Token string 314 | // Asynchronous 315 | Txid string 316 | } 317 | } 318 | 319 | // Duo's Auth method. https://www.duosecurity.com/docs/authapi#/auth 320 | // Factor must be one of 'auto', 'push', 'passcode', 'sms' or 'phone'. 321 | // Use AuthUserId to specify the user_id. 322 | // Use AuthUsername to specify the username. You must specify either AuthUserId 323 | // or AuthUsername, but not both. 324 | // Use AuthIpAddr to include the client's IP address. 325 | // Use AuthAsync to toggle whether the call blocks for the user's response or not. 326 | // If used asynchronously, get the auth status with the AuthStatus method. 327 | // When using factor 'push', use AuthDevice to specify the device ID to push to. 328 | // When using factor 'push', use AuthType to display some extra auth text to the user. 329 | // When using factor 'push', use AuthDisplayUsername to display some extra text 330 | // to the user. 331 | // When using factor 'push', use AuthPushInfo to include some URL-encoded key/value 332 | // pairs to display to the user. 333 | // When using factor 'passcode', use AuthPasscode to specify the passcode entered 334 | // by the user. 335 | // When using factor 'sms' or 'phone', use AuthDevice to specify which device 336 | // should receive the SMS or phone call. 337 | func (api *AuthApi) Auth(factor string, options ...func(*url.Values)) (*AuthResult, error) { 338 | params := url.Values{} 339 | for _, o := range options { 340 | o(¶ms) 341 | } 342 | params.Set("factor", factor) 343 | 344 | var apiOps []duoapi.DuoApiOption 345 | if _, ok := params["async"]; ok == true { 346 | apiOps = append(apiOps, duoapi.UseTimeout) 347 | } 348 | 349 | _, body, err := api.SignedCall("POST", "/auth/v2/auth", params, apiOps...) 350 | if err != nil { 351 | return nil, err 352 | } 353 | ret := &AuthResult{} 354 | if err = json.Unmarshal(body, ret); err != nil { 355 | return nil, err 356 | } 357 | ret.SyncCode() 358 | return ret, nil 359 | } 360 | 361 | // AuthStatus return type. 362 | type AuthStatusResult struct { 363 | duoapi.StatResult 364 | Response struct { 365 | Result string 366 | Status string 367 | Status_Msg string 368 | Trusted_Device_Token string 369 | } 370 | } 371 | 372 | // Duo's auth_status method. https://www.duosecurity.com/docs/authapi#/auth_status 373 | // When using the Auth call in async mode, use this method to retrieve the 374 | // result of the authentication attempt. 375 | // txid is returned by the Auth call. 376 | func (api *AuthApi) AuthStatus(txid string) (*AuthStatusResult, error) { 377 | opts := url.Values{} 378 | opts.Set("txid", txid) 379 | _, body, err := api.SignedCall("GET", "/auth/v2/auth_status", opts) 380 | if err != nil { 381 | return nil, err 382 | } 383 | ret := &AuthStatusResult{} 384 | if err = json.Unmarshal(body, ret); err != nil { 385 | return nil, err 386 | } 387 | ret.SyncCode() 388 | return ret, nil 389 | } 390 | -------------------------------------------------------------------------------- /authapi/authapi_test.go: -------------------------------------------------------------------------------- 1 | package authapi 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | duoapi "github.com/duosecurity/duo_api_golang" 16 | ) 17 | 18 | func buildAuthApi(url string, proxy func(*http.Request) (*url.URL, error)) *AuthApi { 19 | ikey := "eyekey" 20 | skey := "esskey" 21 | host := strings.Split(url, "//")[1] 22 | userAgent := "GoTestClient" 23 | return NewAuthApi(*duoapi.NewDuoApi(ikey, 24 | skey, 25 | host, 26 | userAgent, 27 | duoapi.SetTimeout(1*time.Second), 28 | duoapi.SetInsecure(), 29 | duoapi.SetProxy(proxy))) 30 | } 31 | 32 | func getBodyParams(r *http.Request) (url.Values, error) { 33 | body, err := ioutil.ReadAll(r.Body) 34 | r.Body.Close() 35 | if err != nil { 36 | return url.Values{}, err 37 | } 38 | req_params, err := url.ParseQuery(string(body)) 39 | return req_params, err 40 | } 41 | 42 | // Timeouts are set to 1 second. Take 15 seconds to respond and verify 43 | // that the client times out. 44 | func TestTimeout(t *testing.T) { 45 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | time.Sleep(15 * time.Second) 47 | })) 48 | 49 | duo := buildAuthApi(ts.URL, nil) 50 | 51 | start := time.Now() 52 | _, err := duo.Ping() 53 | duration := time.Since(start) 54 | if duration.Seconds() > 2 { 55 | t.Errorf("Timeout took %v seconds", duration.Seconds()) 56 | } 57 | if err == nil { 58 | t.Error("Expected timeout error.") 59 | } 60 | } 61 | 62 | // Test a successful ping request / response. 63 | func TestPing(t *testing.T) { 64 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 | fmt.Fprintln(w, ` 66 | { 67 | "stat": "OK", 68 | "response": { 69 | "time": 1357020061, 70 | "unexpected_parameter" : "blah" 71 | } 72 | }`) 73 | })) 74 | defer ts.Close() 75 | 76 | duo := buildAuthApi(ts.URL, nil) 77 | 78 | result, err := duo.Ping() 79 | if err != nil { 80 | t.Error("Unexpected error from Ping call" + err.Error()) 81 | } 82 | if result.Stat != "OK" { 83 | t.Error("Expected OK, but got " + result.Stat) 84 | } 85 | if result.Response.Time != 1357020061 { 86 | t.Errorf("Expected 1357020061, but got %d", result.Response.Time) 87 | } 88 | } 89 | 90 | // Test a successful Check request / response. 91 | func TestCheck(t *testing.T) { 92 | ts := httptest.NewTLSServer( 93 | http.HandlerFunc( 94 | func(w http.ResponseWriter, r *http.Request) { 95 | fmt.Fprintln(w, ` 96 | { 97 | "stat": "OK", 98 | "response": { 99 | "time": 1357020061 100 | } 101 | }`) 102 | })) 103 | defer ts.Close() 104 | 105 | duo := buildAuthApi(ts.URL, nil) 106 | 107 | result, err := duo.Check() 108 | if err != nil { 109 | t.Error("Failed TestCheck: " + err.Error()) 110 | } 111 | if result.Stat != "OK" { 112 | t.Error("Expected OK, but got " + result.Stat) 113 | } 114 | if result.Response.Time != 1357020061 { 115 | t.Errorf("Expected 1357020061, but got %d", result.Response.Time) 116 | } 117 | } 118 | 119 | // Test a successful Check request / response through a proxy 120 | func TestProxy(t *testing.T) { 121 | // Proxy server 122 | ps := httptest.NewServer( 123 | http.HandlerFunc( 124 | func(w http.ResponseWriter, r *http.Request) { 125 | if r.Method == "CONNECT" { 126 | // Proxy the connection through to the requested host. 127 | conn, err := net.Dial("tcp", r.URL.Host) 128 | if err != nil { 129 | t.Error("Failed to connect to " + r.URL.String() + ", " + err.Error()) 130 | return 131 | } 132 | // Take over the request connection. 133 | hj, _ := w.(http.Hijacker) 134 | reqconn, _, err := hj.Hijack() 135 | if err != nil { 136 | http.Error(w, err.Error(), http.StatusInternalServerError) 137 | return 138 | } 139 | // Tell the client that everything is going to be OK. 140 | reqconn.Write([]byte("HTTP/1.0 200 OK\r\n\r\n")) 141 | // Copy all the things. 142 | f := func(src, dst net.Conn) { 143 | defer src.Close() 144 | io.Copy(src, dst) 145 | } 146 | go f(conn, reqconn) 147 | go f(reqconn, conn) 148 | } else { 149 | t.Error("Expected CONNECT, but got " + r.Method) 150 | } 151 | })) 152 | defer ps.Close() 153 | 154 | // Duo dummy server response. 155 | ts := httptest.NewTLSServer( 156 | http.HandlerFunc( 157 | func(w http.ResponseWriter, r *http.Request) { 158 | fmt.Fprintln(w, ` 159 | { 160 | "stat": "OK", 161 | "response": { 162 | "time": 1357020061 163 | } 164 | }`) 165 | })) 166 | defer ts.Close() 167 | 168 | // Connect through the test proxy. 169 | proxy_url, err := url.Parse(ps.URL) 170 | duo := buildAuthApi(ts.URL, http.ProxyURL(proxy_url)) 171 | 172 | result, err := duo.Check() 173 | if err != nil { 174 | t.Fatal("Failed TestCheck: " + err.Error()) 175 | } 176 | if result.Stat != "OK" { 177 | t.Error("Expected OK, but got " + result.Stat) 178 | } 179 | if result.Response.Time != 1357020061 { 180 | t.Errorf("Expected 1357020061, but got %d", result.Response.Time) 181 | } 182 | } 183 | 184 | // Test a successful logo request / response. 185 | func TestLogo(t *testing.T) { 186 | ts := httptest.NewTLSServer( 187 | http.HandlerFunc( 188 | func(w http.ResponseWriter, r *http.Request) { 189 | w.Header().Set("Content-Type", "image/png") 190 | w.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00" + 191 | "\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00" + 192 | "\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" + 193 | "\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00" + 194 | "\x00\x00\x00IEND\xaeB`\x82")) 195 | })) 196 | defer ts.Close() 197 | 198 | duo := buildAuthApi(ts.URL, nil) 199 | 200 | _, err := duo.Logo() 201 | if err != nil { 202 | t.Error("Failed TestCheck: " + err.Error()) 203 | } 204 | } 205 | 206 | // Test a failure logo request / response. 207 | func TestLogoError(t *testing.T) { 208 | ts := httptest.NewTLSServer( 209 | http.HandlerFunc( 210 | func(w http.ResponseWriter, r *http.Request) { 211 | // Return a 400, as if the logo was not found. 212 | w.WriteHeader(400) 213 | fmt.Fprintln(w, ` 214 | { 215 | "stat": "FAIL", 216 | "code": 40002, 217 | "message": "Logo not found", 218 | "message_detail": "Why u no have logo?" 219 | }`) 220 | })) 221 | defer ts.Close() 222 | 223 | duo := buildAuthApi(ts.URL, nil) 224 | 225 | res, err := duo.Logo() 226 | if err != nil { 227 | t.Error("Failed TestCheck: " + err.Error()) 228 | } 229 | if res.Stat != "FAIL" { 230 | t.Error("Expected FAIL, but got " + res.Stat) 231 | } 232 | if res.Code == nil || *res.Code != 40002 { 233 | t.Error("Unexpected response code.") 234 | } 235 | if res.Message == nil || *res.Message != "Logo not found" { 236 | t.Error("Unexpected message.") 237 | } 238 | if res.Message_Detail == nil || *res.Message_Detail != "Why u no have logo?" { 239 | t.Error("Unexpected message detail.") 240 | } 241 | } 242 | 243 | // Test a successful enroll request / response. 244 | func TestEnroll(t *testing.T) { 245 | ts := httptest.NewTLSServer( 246 | http.HandlerFunc( 247 | func(w http.ResponseWriter, r *http.Request) { 248 | req_params, err := getBodyParams(r) 249 | if err != nil { 250 | t.Error("Failed to retrieve body parameters") 251 | } 252 | if req_params.Get("username") != "49c6c3097adb386048c84354d82ea63d" { 253 | t.Error("TestEnroll failed to set 'username' query parameter:" + 254 | r.RequestURI) 255 | } 256 | if req_params.Get("valid_secs") != "10" { 257 | t.Error("TestEnroll failed to set 'valid_secs' query parameter: " + 258 | r.RequestURI) 259 | } 260 | fmt.Fprintln(w, ` 261 | { 262 | "stat": "OK", 263 | "response": { 264 | "activation_barcode": "https://api-eval.duosecurity.com/frame/qr?value=8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA", 265 | "activation_code": "duo://8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA", 266 | "expiration": 1357020061, 267 | "user_id": "DU94SWSN4ADHHJHF2HXT", 268 | "username": "49c6c3097adb386048c84354d82ea63d" 269 | } 270 | }`) 271 | })) 272 | defer ts.Close() 273 | 274 | duo := buildAuthApi(ts.URL, nil) 275 | 276 | result, err := duo.Enroll(EnrollUsername("49c6c3097adb386048c84354d82ea63d"), EnrollValidSeconds(10)) 277 | if err != nil { 278 | t.Error("Failed TestEnroll: " + err.Error()) 279 | } 280 | if result.Stat != "OK" { 281 | t.Error("Expected OK, but got " + result.Stat) 282 | } 283 | if result.Response.Activation_Barcode != "https://api-eval.duosecurity.com/frame/qr?value=8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA" { 284 | t.Error("Unexpected activation_barcode: " + result.Response.Activation_Barcode) 285 | } 286 | if result.Response.Activation_Code != "duo://8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA" { 287 | t.Error("Unexpected activation code: " + result.Response.Activation_Code) 288 | } 289 | if result.Response.Expiration != 1357020061 { 290 | t.Errorf("Unexpected expiration time: %d", result.Response.Expiration) 291 | } 292 | if result.Response.User_Id != "DU94SWSN4ADHHJHF2HXT" { 293 | t.Error("Unexpected user id: " + result.Response.User_Id) 294 | } 295 | if result.Response.Username != "49c6c3097adb386048c84354d82ea63d" { 296 | t.Error("Unexpected username: " + result.Response.Username) 297 | } 298 | } 299 | 300 | // Test a succesful enroll status request / response. 301 | func TestEnrollStatus(t *testing.T) { 302 | ts := httptest.NewTLSServer( 303 | http.HandlerFunc( 304 | func(w http.ResponseWriter, r *http.Request) { 305 | req_params, err := getBodyParams(r) 306 | if err != nil { 307 | t.Error("Failed to retrieve body parameters") 308 | } 309 | if req_params.Get("user_id") != "49c6c3097adb386048c84354d82ea63d" { 310 | t.Error("TestEnrollStatus failed to set 'user_id' query parameter:" + 311 | r.RequestURI) 312 | } 313 | if req_params.Get("activation_code") != "10" { 314 | t.Error("TestEnrollStatus failed to set 'activation_code' query parameter: " + 315 | r.RequestURI) 316 | } 317 | fmt.Fprintln(w, ` 318 | { 319 | "stat": "OK", 320 | "response": "success" 321 | }`) 322 | })) 323 | defer ts.Close() 324 | 325 | duo := buildAuthApi(ts.URL, nil) 326 | 327 | result, err := duo.EnrollStatus("49c6c3097adb386048c84354d82ea63d", "10") 328 | if err != nil { 329 | t.Error("Failed TestEnrollStatus: " + err.Error()) 330 | } 331 | if result.Stat != "OK" { 332 | t.Error("Expected OK, but got " + result.Stat) 333 | } 334 | if result.Response != "success" { 335 | t.Error("Unexpected response: " + result.Response) 336 | } 337 | } 338 | 339 | // Test a successful preauth with user id. The client doesn't enforce api requirements, 340 | // such as requiring only one of user id or username, but we'll cover the username 341 | // in another test anyway. 342 | func TestPreauthUserId(t *testing.T) { 343 | ts := httptest.NewTLSServer( 344 | http.HandlerFunc( 345 | func(w http.ResponseWriter, r *http.Request) { 346 | req_params, err := getBodyParams(r) 347 | if err != nil { 348 | t.Error("Failed to retrieve body parameters") 349 | } 350 | if req_params.Get("ipaddr") != "127.0.0.1" { 351 | t.Error("TestPreauth failed to set 'ipaddr' query parameter:" + 352 | r.RequestURI) 353 | } 354 | if req_params.Get("user_id") != "10" { 355 | t.Error("TestEnrollStatus failed to set 'user_id' query parameter: " + 356 | r.RequestURI) 357 | } 358 | if req_params.Get("trusted_device_token") != "l33t" { 359 | t.Error("TestEnrollStatus failed to set 'trusted_device_token' query parameter: " + 360 | r.RequestURI) 361 | } 362 | fmt.Fprintln(w, ` 363 | { 364 | "stat": "OK", 365 | "response": { 366 | "result": "auth", 367 | "status_msg": "Account is active", 368 | "devices": [ 369 | { 370 | "device": "DPFZRS9FB0D46QFTM891", 371 | "type": "phone", 372 | "number": "XXX-XXX-0100", 373 | "name": "", 374 | "capabilities": [ 375 | "push", 376 | "sms", 377 | "phone" 378 | ] 379 | }, 380 | { 381 | "device": "DHEKH0JJIYC1LX3AZWO4", 382 | "type": "token", 383 | "name": "0" 384 | } 385 | ] 386 | } 387 | }`) 388 | })) 389 | defer ts.Close() 390 | 391 | duo := buildAuthApi(ts.URL, nil) 392 | 393 | res, err := duo.Preauth(PreauthUserId("10"), PreauthIpAddr("127.0.0.1"), PreauthTrustedToken("l33t")) 394 | if err != nil { 395 | t.Error("Failed TestPreauthUserId: " + err.Error()) 396 | } 397 | if res.Stat != "OK" { 398 | t.Error("Unexpected stat: " + res.Stat) 399 | } 400 | if res.Response.Result != "auth" { 401 | t.Error("Unexpected response result: " + res.Response.Result) 402 | } 403 | if res.Response.Status_Msg != "Account is active" { 404 | t.Error("Unexpected status message: " + res.Response.Status_Msg) 405 | } 406 | if len(res.Response.Devices) != 2 { 407 | t.Errorf("Unexpected devices length: %d", len(res.Response.Devices)) 408 | } 409 | if res.Response.Devices[0].Device != "DPFZRS9FB0D46QFTM891" { 410 | t.Error("Unexpected [0] device name: " + res.Response.Devices[0].Device) 411 | } 412 | if res.Response.Devices[0].Type != "phone" { 413 | t.Error("Unexpected [0] device type: " + res.Response.Devices[0].Type) 414 | } 415 | if res.Response.Devices[0].Number != "XXX-XXX-0100" { 416 | t.Error("Unexpected [0] device number: " + res.Response.Devices[0].Number) 417 | } 418 | if res.Response.Devices[0].Name != "" { 419 | t.Error("Unexpected [0] devices name :" + res.Response.Devices[0].Name) 420 | } 421 | if len(res.Response.Devices[0].Capabilities) != 3 { 422 | t.Errorf("Unexpected [0] device capabilities length: %d", len(res.Response.Devices[0].Capabilities)) 423 | } 424 | if res.Response.Devices[0].Capabilities[0] != "push" { 425 | t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[0]) 426 | } 427 | if res.Response.Devices[0].Capabilities[1] != "sms" { 428 | t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[1]) 429 | } 430 | if res.Response.Devices[0].Capabilities[2] != "phone" { 431 | t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[2]) 432 | } 433 | if res.Response.Devices[1].Device != "DHEKH0JJIYC1LX3AZWO4" { 434 | t.Error("Unexpected [1] device name: " + res.Response.Devices[1].Device) 435 | } 436 | if res.Response.Devices[1].Type != "token" { 437 | t.Error("Unexpected [1] device type: " + res.Response.Devices[1].Type) 438 | } 439 | if res.Response.Devices[1].Name != "0" { 440 | t.Error("Unexpected [1] devices name :" + res.Response.Devices[1].Name) 441 | } 442 | if len(res.Response.Devices[1].Capabilities) != 0 { 443 | t.Errorf("Unexpected [1] device capabilities length: %d", len(res.Response.Devices[1].Capabilities)) 444 | } 445 | } 446 | 447 | // Test preauth enroll with username, and an enroll response. 448 | func TestPreauthEnroll(t *testing.T) { 449 | ts := httptest.NewTLSServer( 450 | http.HandlerFunc( 451 | func(w http.ResponseWriter, r *http.Request) { 452 | req_params, err := getBodyParams(r) 453 | if err != nil { 454 | t.Error("Failed to retrieve body parameters") 455 | } 456 | if req_params.Get("username") != "10" { 457 | t.Error("TestEnrollStatus failed to set 'username' query parameter: " + 458 | r.RequestURI) 459 | } 460 | fmt.Fprintln(w, ` 461 | { 462 | "stat": "OK", 463 | "response": { 464 | "enroll_portal_url": "https://api-3945ef22.duosecurity.com/portal?48bac5d9393fb2c2", 465 | "result": "enroll", 466 | "status_msg": "Enroll an authentication device to proceed" 467 | } 468 | }`) 469 | })) 470 | defer ts.Close() 471 | 472 | duo := buildAuthApi(ts.URL, nil) 473 | 474 | res, err := duo.Preauth(PreauthUsername("10")) 475 | if err != nil { 476 | t.Error("Failed TestPreauthEnroll: " + err.Error()) 477 | } 478 | if res.Stat != "OK" { 479 | t.Error("Unexpected stat: " + res.Stat) 480 | } 481 | if res.Response.Enroll_Portal_Url != "https://api-3945ef22.duosecurity.com/portal?48bac5d9393fb2c2" { 482 | t.Error("Unexpected enroll portal URL: " + res.Response.Enroll_Portal_Url) 483 | } 484 | if res.Response.Result != "enroll" { 485 | t.Error("Unexpected response result: " + res.Response.Result) 486 | } 487 | if res.Response.Status_Msg != "Enroll an authentication device to proceed" { 488 | t.Error("Unexpected status msg: " + res.Response.Status_Msg) 489 | } 490 | } 491 | 492 | // Test an authentication request / response. This won't work against the Duo 493 | // server, because the request parameters included are illegal. But we can 494 | // verify that the go code sets the query parameters correctly. 495 | func TestAuth(t *testing.T) { 496 | ts := httptest.NewTLSServer( 497 | http.HandlerFunc( 498 | func(w http.ResponseWriter, r *http.Request) { 499 | req_params, err := getBodyParams(r) 500 | if err != nil { 501 | t.Error("Failed to retrieve body parameters") 502 | } 503 | expected := map[string]string{ 504 | "username": "username value", 505 | "user_id": "user_id value", 506 | "factor": "auto", 507 | "ipaddr": "40.40.40.10", 508 | "async": "1", 509 | "device": "primary", 510 | "type": "request", 511 | "display_username": "display username", 512 | } 513 | for key, value := range expected { 514 | if req_params.Get(key) != value { 515 | t.Errorf("TestAuth failed to set '%s' query parameter: "+ 516 | r.RequestURI, key) 517 | } 518 | } 519 | fmt.Fprintln(w, ` 520 | { 521 | "stat": "OK", 522 | "response": { 523 | "result": "allow", 524 | "status": "allow", 525 | "status_msg": "Success. Logging you in..." 526 | } 527 | }`) 528 | })) 529 | defer ts.Close() 530 | 531 | duo := buildAuthApi(ts.URL, nil) 532 | 533 | res, err := duo.Auth("auto", 534 | AuthUserId("user_id value"), 535 | AuthUsername("username value"), 536 | AuthIpAddr("40.40.40.10"), 537 | AuthAsync(), 538 | AuthDevice("primary"), 539 | AuthType("request"), 540 | AuthDisplayUsername("display username"), 541 | ) 542 | if err != nil { 543 | t.Error("Failed TestAuth: " + err.Error()) 544 | } 545 | if res.Stat != "OK" { 546 | t.Error("Unexpected stat: " + res.Stat) 547 | } 548 | if res.Response.Result != "allow" { 549 | t.Error("Unexpected response result: " + res.Response.Result) 550 | } 551 | if res.Response.Status != "allow" { 552 | t.Error("Unexpected response status: " + res.Response.Status) 553 | } 554 | if res.Response.Status_Msg != "Success. Logging you in..." { 555 | t.Error("Unexpected response status msg: " + res.Response.Status_Msg) 556 | } 557 | } 558 | 559 | // Test AuthStatus request / response. 560 | func TestAuthStatus(t *testing.T) { 561 | ts := httptest.NewTLSServer( 562 | http.HandlerFunc( 563 | func(w http.ResponseWriter, r *http.Request) { 564 | expected := map[string]string{ 565 | "txid": "4", 566 | } 567 | for key, value := range expected { 568 | if r.FormValue(key) != value { 569 | t.Errorf("TestAuthStatus failed to set '%s' query parameter: "+ 570 | r.RequestURI, key) 571 | } 572 | } 573 | fmt.Fprintln(w, ` 574 | { 575 | "stat": "OK", 576 | "response": { 577 | "result": "waiting", 578 | "status": "pushed", 579 | "status_msg": "Pushed a login request to your phone..." 580 | } 581 | }`) 582 | })) 583 | defer ts.Close() 584 | 585 | duo := buildAuthApi(ts.URL, nil) 586 | 587 | res, err := duo.AuthStatus("4") 588 | if err != nil { 589 | t.Error("Failed TestAuthStatus: " + err.Error()) 590 | } 591 | 592 | if res.Stat != "OK" { 593 | t.Error("Unexpected stat: " + res.Stat) 594 | } 595 | if res.Response.Result != "waiting" { 596 | t.Error("Unexpected response result: " + res.Response.Result) 597 | } 598 | if res.Response.Status != "pushed" { 599 | t.Error("Unexpected response status: " + res.Response.Status) 600 | } 601 | if res.Response.Status_Msg != "Pushed a login request to your phone..." { 602 | t.Error("Unexpected response status msg: " + res.Response.Status_Msg) 603 | } 604 | } 605 | 606 | // Test a response with empty code. 607 | func TestEmptyResponseCode(t *testing.T) { 608 | ts := httptest.NewTLSServer( 609 | http.HandlerFunc( 610 | func(w http.ResponseWriter, r *http.Request) { 611 | // Return a 400, as if the logo was not found. 612 | w.WriteHeader(400) 613 | fmt.Fprintln(w, ` 614 | { 615 | "stat": "FAIL", 616 | "code": "", 617 | "message": "Code is empty", 618 | "message_detail": "Deal with it" 619 | }`) 620 | })) 621 | defer ts.Close() 622 | 623 | duo := buildAuthApi(ts.URL, nil) 624 | 625 | res, err := duo.Logo() 626 | if err != nil { 627 | t.Error("Failed TestCheck: " + err.Error()) 628 | } 629 | if res.Stat != "FAIL" { 630 | t.Error("Expected FAIL, but got " + res.Stat) 631 | } 632 | if res.Code == nil || *res.Code != 0 { 633 | t.Error("Unexpected response code.") 634 | } 635 | if res.Message == nil || *res.Message != "Code is empty" { 636 | t.Error("Unexpected message.") 637 | } 638 | if res.Message_Detail == nil || *res.Message_Detail != "Deal with it" { 639 | t.Error("Unexpected message detail.") 640 | } 641 | } 642 | -------------------------------------------------------------------------------- /duo_test.go: -------------------------------------------------------------------------------- 1 | package duoapi 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "reflect" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestCanonicalize(t *testing.T) { 17 | values := url.Values{} 18 | values.Set("username", "H ell?o") 19 | values.Set("password", "H-._~i") 20 | values.Add("password", "A(!'*)") 21 | params_str := canonicalize("post", 22 | "API-XXX.duosecurity.COM", 23 | "/auth/v2/ping", 24 | values, 25 | "5") 26 | params := strings.Split(params_str, "\n") 27 | if len(params) != 5 { 28 | t.Error("Expected 5 parameters, but got " + strconv.Itoa(len(params))) 29 | } 30 | if params[1] != string("POST") { 31 | t.Error("Expected POST, but got " + params[1]) 32 | } 33 | if params[2] != string("api-xxx.duosecurity.com") { 34 | t.Error("Expected api-xxx.duosecurity.com, but got " + params[2]) 35 | } 36 | if params[3] != string("/auth/v2/ping") { 37 | t.Error("Expected /auth/v2/ping, but got " + params[3]) 38 | } 39 | if params[4] != string("password=A%28%21%27%2A%29&password=H-._~i&username=H%20ell%3Fo") { 40 | t.Error("Expected sorted escaped params, but got " + params[4]) 41 | } 42 | } 43 | 44 | func encodeAndValidate(t *testing.T, input url.Values, output string) { 45 | values := url.Values{} 46 | for key, val := range input { 47 | values.Set(key, val[0]) 48 | } 49 | params_str := canonicalize("post", 50 | "API-XXX.duosecurity.com", 51 | "/auth/v2/ping", 52 | values, 53 | "5") 54 | params := strings.Split(params_str, "\n") 55 | if params[4] != output { 56 | t.Error("Mismatch\n" + output + "\n" + params[4]) 57 | } 58 | 59 | } 60 | 61 | func TestSimple(t *testing.T) { 62 | values := url.Values{} 63 | values.Set("realname", "First Last") 64 | values.Set("username", "root") 65 | 66 | encodeAndValidate(t, values, "realname=First%20Last&username=root") 67 | } 68 | 69 | func TestZero(t *testing.T) { 70 | values := url.Values{} 71 | encodeAndValidate(t, values, "") 72 | } 73 | 74 | func TestOne(t *testing.T) { 75 | values := url.Values{} 76 | values.Set("realname", "First Last") 77 | encodeAndValidate(t, values, "realname=First%20Last") 78 | } 79 | 80 | func TestPrintableAsciiCharaceters(t *testing.T) { 81 | values := url.Values{} 82 | values.Set("digits", "0123456789") 83 | values.Set("letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 84 | values.Set("punctuation", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~") 85 | values.Set("whitespace", "\t\n\x0b\x0c\r ") 86 | 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") 87 | } 88 | 89 | func TestSortOrderWithCommonPrefix(t *testing.T) { 90 | values := url.Values{} 91 | values.Set("foo", "1") 92 | values.Set("foo_bar", "2") 93 | encodeAndValidate(t, values, "foo=1&foo_bar=2") 94 | } 95 | 96 | func TestUnicodeFuzzValues(t *testing.T) { 97 | values := url.Values{} 98 | values.Set("bar", "⠕ꪣ㟏䮷㛩찅暎腢슽ꇱ") 99 | values.Set("baz", "ෳ蒽噩馅뢤갺篧潩鍊뤜") 100 | values.Set("foo", "퓎훖礸僀訠輕ﴋ耤岳왕") 101 | values.Set("qux", "讗졆-芎茚쳊ꋔ谾뢲馾") 102 | 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") 103 | } 104 | 105 | func TestUnicodeFuzzKeysAndValues(t *testing.T) { 106 | values := url.Values{} 107 | values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰", 108 | "ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐") 109 | values.Set("瑉繋쳻姿﹟获귌逌쿑砓", 110 | "趷倢鋓䋯⁽蜰곾嘗ॆ丰") 111 | values.Set("瑰錔逜麮䃘䈁苘豰ᴱꁂ", 112 | "៙ந鍘꫟ꐪ䢾ﮖ濩럿㋳") 113 | values.Set("싅Ⱍ☠㘗隳F蘅⃨갡头", 114 | "ﮩ䆪붃萋☕㹮攭ꢵ핫U") 115 | 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") 116 | } 117 | 118 | func TestSign(t *testing.T) { 119 | values := url.Values{} 120 | values.Set("realname", "First Last") 121 | values.Set("username", "root") 122 | res := sign("DIWJ8X6AEYOR5OMC6TQ1", 123 | "Zh5eGmUq9zpfQnyUIu5OL9iWoMMv5ZNmk3zLJ4Ep", 124 | "POST", 125 | "api-XXXXXXXX.duosecurity.com", 126 | "/accounts/v1/account/list", 127 | "Tue, 21 Aug 2012 17:29:18 -0000", 128 | values) 129 | if res != "Basic RElXSjhYNkFFWU9SNU9NQzZUUTE6MWJmZmI4OTI0MzQ4ZjdkYjdkYTllN2Q3ZDc0OWRlZDkzYWFmZDAyZDhlOTA0OTYwZGE3Yjk2YzU3NTEwMjIwMTg1YTY0YTI4MjdlZThmMjRhYzVkMzA4MDJhOWVlOTdlNTlkZTQ0YjMwNGIxODI0MmYwZDU5NmQxNWE4MTIyYWY=" { 130 | t.Error("Signature did not produce output documented at " + 131 | "https://www.duosecurity.com/docs/authapi :(") 132 | } 133 | } 134 | 135 | func TestSignV5(t *testing.T) { 136 | values := url.Values{} 137 | values.Set("realname", "First Last") 138 | body := "{\"txid\":\"f22b1678-252a-4070-b176-0ca2be7319fd\"}" 139 | res := signV5("DIWJ8X6AEYOR5OMC6TQ1", 140 | "Zh5eGmUq9zpfQnyUIu5OL9iWoMMv5ZNmk3zLJ4Ep", 141 | "POST", 142 | "api-XXXXXXXX.duosecurity.com", 143 | "/accounts/v1/account/list", 144 | "Tue, 21 Aug 2012 17:29:18 -0000", 145 | values, 146 | body) 147 | expected := "Basic RElXSjhYNkFFWU9SNU9NQzZUUTE6NzhmNDMyN2Y4MzExNzNjYzc4ZDA5MDdlOTEzZTNjNWEyOGZlNzJkZDQ1NDVhMzQyNTg2YmI2NzE4MWYyYmEzOTNkMjA5MTFlODcwMzYyZjZmYWJhM2RjNmY3ZTlkYjVlOTNhZWQyZjNiZmMxMTBjNmRhZGFmZjRkYzYxNzllMGI=" 148 | if res != expected { 149 | t.Error("Mismatch between expected and received\n" + "Expected: " + expected + "\nReceived: " + res) 150 | } 151 | 152 | } 153 | 154 | func TestCanonicalizeV2(t *testing.T) { 155 | values := url.Values{} 156 | values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰", 157 | "ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐") 158 | values.Set("瑉繋쳻姿﹟获귌逌쿑砓", 159 | "趷倢鋓䋯⁽蜰곾嘗ॆ丰") 160 | values.Set("瑰錔逜麮䃘䈁苘豰ᴱꁂ", 161 | "៙ந鍘꫟ꐪ䢾ﮖ濩럿㋳") 162 | values.Set("싅Ⱍ☠㘗隳F蘅⃨갡头", 163 | "ﮩ䆪붃萋☕㹮攭ꢵ핫U") 164 | canon := canonicalize( 165 | "PoSt", 166 | "foO.BAr52.cOm", 167 | "/Foo/BaR2/qux", 168 | values, 169 | "Fri, 07 Dec 2012 17:18:00 -0000") 170 | 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" 171 | if canon != expected { 172 | t.Error("Mismatch!\n" + expected + "\n" + canon) 173 | } 174 | } 175 | 176 | func TestCanonicalizeV5(t *testing.T) { 177 | values := url.Values{} 178 | values.Set("username", "H ell?o") 179 | body := "{\"activation_code\":\"duo://x1bTAIQGWXppdi2ctPVn-YXBpLWR1bzEuZHVvLnRlc3Q\",\"user_id\":\"DU439XKOX2W6LMYHWLEV\"}" 180 | canon := canonicalizeV5( 181 | "post", 182 | "FOO.example.CoM", 183 | "/Foo/BaR2/qux", 184 | values, 185 | body, 186 | "Fri, 07 Dec 2012 17:18:00 -0000") 187 | expected := `Fri, 07 Dec 2012 17:18:00 -0000 188 | POST 189 | foo.example.com 190 | /Foo/BaR2/qux 191 | username=H%20ell%3Fo 192 | 9ddab7d898836a76fafbb0dcef7bc83f14036b39bbb1ebbe43044f7c76338fa699eba8f0d3ecb329a084f32ef23c8a1609efad25032923b651e6f3f6d2f7b773 193 | cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e` 194 | if canon != expected { 195 | t.Error("Mismatch between expected and received\n" + "Expected: " + expected + "\nReceived: " + canon) 196 | } 197 | } 198 | 199 | func TestNewDuo(t *testing.T) { 200 | duo := NewDuoApi("ABC", "123", "api-XXXXXXX.duosecurity.com", "go-client") 201 | if duo == nil { 202 | t.Fatal("Failed to create a new Duo Api") 203 | } 204 | } 205 | 206 | func TestSetTransport(t *testing.T) { 207 | transportOpt := func(tr *http.Transport) { 208 | tr.MaxResponseHeaderBytes = 12345 209 | } 210 | 211 | duo := NewDuoApi("ABC", "123", "api-XXXXXXX.duosecurity.com", "go-client", SetTransport(transportOpt)) 212 | 213 | httpClient := duo.apiClient.(*http.Client) 214 | transport := httpClient.Transport.(*http.Transport) 215 | if transport.MaxResponseHeaderBytes != 12345 { 216 | t.Fatal("SetTransport failed to update Duo HTTP client transport configuration") 217 | } 218 | } 219 | 220 | func TestDuoApiCallHttpErr(t *testing.T) { 221 | httpClient := &mockHttpClient{doError: true} 222 | sleepSvc := &mockSleepService{} 223 | 224 | duo := &DuoApi{ 225 | ikey: "ikey-foo", 226 | skey: "skey-bar", 227 | host: "host.baz", 228 | userAgent: "ua-qux", 229 | apiClient: httpClient, 230 | authClient: httpClient, 231 | sleepSvc: sleepSvc, 232 | } 233 | resp, body, err := duo.Call("GET", "/v9/hello/world", url.Values{}) 234 | if resp != nil { 235 | t.Fatal("Non nil response returned") 236 | } 237 | if len(body) != 0 { 238 | t.Fatal("Non empty body returned") 239 | } 240 | if err == nil { 241 | t.Fatal("No error returned") 242 | } 243 | if len(httpClient.actualRequests) != 1 { 244 | t.Fatal("We should not retry after an HTTP error") 245 | } 246 | } 247 | 248 | func getMockClients(httpResponses []http.Response) (*DuoApi, *mockHttpClient, *mockSleepService) { 249 | httpClient := &mockHttpClient{responses: httpResponses} 250 | sleepSvc := &mockSleepService{} 251 | 252 | return &DuoApi{ 253 | ikey: "ikey-foo", 254 | skey: "skey-bar", 255 | host: "host.baz", 256 | userAgent: "ua-qux", 257 | apiClient: httpClient, 258 | authClient: httpClient, 259 | sleepSvc: sleepSvc, 260 | }, httpClient, sleepSvc 261 | } 262 | 263 | var okResp = http.Response{ 264 | StatusCode: 200, 265 | Body: ioutil.NopCloser(bytes.NewReader([]byte("hello world"))), 266 | } 267 | var rateLimitResp = http.Response{ 268 | StatusCode: 429, 269 | Body: ioutil.NopCloser(bytes.NewReader([]byte("hello world"))), 270 | } 271 | 272 | var completeRateLimitSleepDurations = []time.Duration{ 273 | time.Millisecond * 1000, 274 | time.Millisecond * 2000, 275 | time.Millisecond * 4000, 276 | time.Millisecond * 8000, 277 | time.Millisecond * 16000, 278 | time.Millisecond * 32000, 279 | } 280 | 281 | func assertRateLimitedCall( 282 | t *testing.T, 283 | actualResponse http.Response, 284 | httpClient mockHttpClient, 285 | sleepSvc mockSleepService, 286 | expectedTotalCalls int, 287 | expectedResponse http.Response, 288 | expectedSleepDurations []time.Duration) { 289 | 290 | if actualResponse.StatusCode != expectedResponse.StatusCode { 291 | t.Fatal("returned response does not have correct status code") 292 | } 293 | if actualResponse.Body != expectedResponse.Body { 294 | t.Fatal("returned response does not have correct body") 295 | } 296 | 297 | retriedRequestCount := expectedTotalCalls - 1 298 | 299 | if len(httpClient.actualRequests) != expectedTotalCalls { 300 | t.Fatal("Made " + strconv.Itoa(len(httpClient.actualRequests)) + 301 | " requests instead of " + strconv.Itoa(expectedTotalCalls)) 302 | } 303 | 304 | if len(sleepSvc.sleepCalls) != retriedRequestCount { 305 | t.Fatal("Made " + strconv.Itoa(len(sleepSvc.sleepCalls)) + 306 | " sleep calls instead of " + strconv.Itoa(retriedRequestCount)) 307 | } 308 | for i := range expectedSleepDurations { 309 | if sleepSvc.sleepCalls[i] != expectedSleepDurations[i] { 310 | t.Fatal("Slept for " + sleepSvc.sleepCalls[i].String() + 311 | " instead of " + expectedSleepDurations[i].String()) 312 | } 313 | } 314 | } 315 | 316 | func TestCallRateLimitedOnce(t *testing.T) { 317 | responses := []http.Response{rateLimitResp, okResp} 318 | sleepDurations := []time.Duration{time.Millisecond * 1000} 319 | 320 | duo, mockHttp, mockSleep := getMockClients(responses) 321 | resp, _, _ := duo.Call("GET", "/v9/hello/world", url.Values{}) 322 | assertRateLimitedCall(t, *resp, *mockHttp, *mockSleep, 2, okResp, sleepDurations) 323 | } 324 | 325 | func TestCallCompletelyRateLimited(t *testing.T) { 326 | responses := make([]http.Response, 7) 327 | for i := range responses { 328 | responses[i] = rateLimitResp 329 | } 330 | 331 | duo, mockHttp, mockSleep := getMockClients(responses) 332 | resp, _, _ := duo.Call("GET", "/v9/hello/world", url.Values{}) 333 | assertRateLimitedCall(t, *resp, *mockHttp, *mockSleep, 334 | 7, rateLimitResp, completeRateLimitSleepDurations) 335 | } 336 | 337 | func TestSignedCallRateLimitedOnce(t *testing.T) { 338 | responses := []http.Response{rateLimitResp, okResp} 339 | sleepDurations := []time.Duration{time.Millisecond * 1000} 340 | 341 | duo, mockHttp, mockSleep := getMockClients(responses) 342 | resp, _, _ := duo.SignedCall("GET", "/v9/hello/world", url.Values{}) 343 | assertRateLimitedCall(t, *resp, *mockHttp, *mockSleep, 2, okResp, sleepDurations) 344 | } 345 | 346 | func TestSignedCallCompletelyRateLimited(t *testing.T) { 347 | responses := make([]http.Response, 7) 348 | for i := range responses { 349 | responses[i] = rateLimitResp 350 | } 351 | 352 | duo, mockHttp, mockSleep := getMockClients(responses) 353 | resp, _, _ := duo.SignedCall("GET", "/v9/hello/world", url.Values{}) 354 | assertRateLimitedCall(t, *resp, *mockHttp, *mockSleep, 355 | 7, rateLimitResp, completeRateLimitSleepDurations) 356 | } 357 | 358 | func TestHashString(t *testing.T) { 359 | body := `{"limit":10,"offset":2}` 360 | expected := "66fabab062974c3dd3f4d27284e41bf8121d71c0e63e95631992062ef5d1a4058403af3482c8c32ae63cd724cbf0aa793a931ef273539ef6f3745751c22f25f6" 361 | res := hashString(body) 362 | if res != expected { 363 | t.Error("Expected hash of body params but got:\n" + res) 364 | } 365 | } 366 | 367 | func TestJSONToValues(t *testing.T) { 368 | json := JSONParams{ 369 | "user_id": "1234", 370 | "activation_code": "1234567890-abcdef", 371 | } 372 | expected := url.Values{ 373 | "activation_code": []string{"1234567890-abcdef"}, 374 | "user_id": []string{"1234"}, 375 | } 376 | res, _ := jsonToValues(json) 377 | if !reflect.DeepEqual(res, expected) { 378 | t.Error("Expected parsed JSON params but got:\n" + res.Encode()) 379 | } 380 | 381 | empty_json := JSONParams{} 382 | empty_expected := url.Values{} 383 | empty_res, _ := jsonToValues(empty_json) 384 | if !reflect.DeepEqual(empty_res, empty_expected) { 385 | t.Error("Expected empty result but got:\n" + res.Encode()) 386 | } 387 | 388 | bad_json := JSONParams{ 389 | "user_id": 1234, 390 | } 391 | expected_err := "JSON value not a string" 392 | _, err := jsonToValues(bad_json) 393 | if err.Error() != expected_err { 394 | t.Error("Expected not a string error but received " + err.Error()) 395 | } 396 | 397 | } 398 | 399 | type mockHttpClient struct { 400 | responses []http.Response 401 | actualRequests []*http.Request 402 | doError bool 403 | } 404 | 405 | func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { 406 | if c.actualRequests == nil { 407 | c.actualRequests = []*http.Request{} 408 | } 409 | c.actualRequests = append(c.actualRequests, req) 410 | if c.doError { 411 | return nil, errors.New("Ouch") 412 | } 413 | 414 | resp := c.responses[0] 415 | c.responses = c.responses[1:] 416 | return &resp, nil 417 | } 418 | 419 | type mockSleepService struct { 420 | sleepCalls []time.Duration 421 | } 422 | 423 | func (svc *mockSleepService) Sleep(duration time.Duration) { 424 | if svc.sleepCalls == nil { 425 | svc.sleepCalls = []time.Duration{} 426 | } 427 | svc.sleepCalls = append(svc.sleepCalls, duration) 428 | } 429 | -------------------------------------------------------------------------------- /duoapi.go: -------------------------------------------------------------------------------- 1 | package duoapi 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha512" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "encoding/base64" 9 | "encoding/hex" 10 | "encoding/json" 11 | "errors" 12 | "io" 13 | "io/ioutil" 14 | "math/rand" 15 | "net/http" 16 | "net/url" 17 | "sort" 18 | "strings" 19 | "time" 20 | ) 21 | 22 | const ( 23 | version = "0.2.0" 24 | defaultUserAgent = "duo_api_golang/" + version 25 | initialBackoffMS = 1000 26 | maxBackoffMS = 32000 27 | backoffFactor = 2 28 | rateLimitHttpCode = 429 29 | ) 30 | 31 | var spaceReplacer *strings.Replacer = strings.NewReplacer("+", "%20") 32 | 33 | func canonParams(params url.Values) string { 34 | // Values must be in sorted order 35 | for key, val := range params { 36 | sort.Strings(val) 37 | params[key] = val 38 | } 39 | // Encode will place Keys in sorted order 40 | ordered_params := params.Encode() 41 | // Encoder turns spaces into +, but we need %XX escaping 42 | return spaceReplacer.Replace(ordered_params) 43 | } 44 | 45 | func canonicalize(method string, 46 | host string, 47 | uri string, 48 | params url.Values, 49 | date string) string { 50 | var canon [5]string 51 | canon[0] = date 52 | canon[1] = strings.ToUpper(method) 53 | canon[2] = strings.ToLower(host) 54 | canon[3] = uri 55 | canon[4] = canonParams(params) 56 | return strings.Join(canon[:], "\n") 57 | } 58 | 59 | func canonicalizeV5(method string, 60 | host string, 61 | uri string, 62 | params url.Values, 63 | body string, 64 | date string) string { 65 | var canon [7]string 66 | canon[0] = date 67 | canon[1] = strings.ToUpper(method) 68 | canon[2] = strings.ToLower(host) 69 | canon[3] = uri 70 | canon[4] = canonParams(params) 71 | canon[5] = hashString(body) 72 | canon[6] = hashString("") // additional headers not needed at this time 73 | return strings.Join(canon[:], "\n") 74 | } 75 | 76 | func hashString(to_hash string) string { 77 | hash := sha512.New() 78 | hash.Write([]byte(to_hash)) 79 | return hex.EncodeToString(hash.Sum(nil)) 80 | } 81 | 82 | func jsonToValues(json JSONParams) (url.Values, error) { 83 | params := url.Values{} 84 | for key, val := range json { 85 | s, ok := val.(string) 86 | if ok { 87 | params[key] = []string{s} 88 | } else { 89 | return nil, errors.New("JSON value not a string") 90 | } 91 | } 92 | return params, nil 93 | } 94 | 95 | func sign(ikey string, 96 | skey string, 97 | method string, 98 | host string, 99 | uri string, 100 | date string, 101 | params url.Values) string { 102 | canon := canonicalize(method, host, uri, params, date) 103 | mac := hmac.New(sha512.New, []byte(skey)) 104 | mac.Write([]byte(canon)) 105 | sig := hex.EncodeToString(mac.Sum(nil)) 106 | auth := ikey + ":" + sig 107 | return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) 108 | } 109 | 110 | func signV5(ikey string, 111 | skey string, 112 | method string, 113 | host string, 114 | uri string, 115 | date string, 116 | params url.Values, 117 | body string, 118 | ) string { 119 | canon := canonicalizeV5(method, host, uri, params, body, date) 120 | mac := hmac.New(sha512.New, []byte(skey)) 121 | mac.Write([]byte(canon)) 122 | sig := hex.EncodeToString(mac.Sum(nil)) 123 | auth := ikey + ":" + sig 124 | return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) 125 | } 126 | 127 | type DuoApi struct { 128 | ikey string 129 | skey string 130 | host string 131 | userAgent string 132 | apiClient httpClient 133 | authClient httpClient 134 | sleepSvc sleepService 135 | } 136 | 137 | type httpClient interface { 138 | Do(req *http.Request) (*http.Response, error) 139 | } 140 | type sleepService interface { 141 | Sleep(duration time.Duration) 142 | } 143 | type timeSleepService struct{} 144 | 145 | func (svc timeSleepService) Sleep(duration time.Duration) { 146 | time.Sleep(duration + (time.Duration(rand.Intn(1000)) * time.Millisecond)) 147 | } 148 | 149 | type apiOptions struct { 150 | timeout time.Duration 151 | insecure bool 152 | proxy func(*http.Request) (*url.URL, error) 153 | transport func(*http.Transport) 154 | } 155 | 156 | // Optional parameter for NewDuoApi, used to configure timeouts on API calls. 157 | func SetTimeout(timeout time.Duration) func(*apiOptions) { 158 | return func(opts *apiOptions) { 159 | opts.timeout = timeout 160 | return 161 | } 162 | } 163 | 164 | // Optional parameter for testing only. Bypasses all TLS certificate validation. 165 | func SetInsecure() func(*apiOptions) { 166 | return func(opts *apiOptions) { 167 | opts.insecure = true 168 | } 169 | } 170 | 171 | // Optional parameter for NewDuoApi, used to configure an HTTP Connect proxy 172 | // server for all outbound communications. 173 | func SetProxy(proxy func(*http.Request) (*url.URL, error)) func(*apiOptions) { 174 | return func(opts *apiOptions) { 175 | opts.proxy = proxy 176 | } 177 | } 178 | 179 | // SetTransport enables additional control over the HTTP transport used to connect to the Duo API. 180 | func SetTransport(transport func(*http.Transport)) func(*apiOptions) { 181 | return func(opts *apiOptions) { 182 | opts.transport = transport 183 | } 184 | } 185 | 186 | // Build an return a DuoApi struct. 187 | // ikey is your Duo integration key 188 | // skey is your Duo integration secret key 189 | // host is your Duo host 190 | // userAgent allows you to specify the user agent string used when making 191 | // the web request to Duo. Information about the client will be 192 | // appended to the userAgent. 193 | // options are optional parameters. Use SetTimeout() to specify a timeout value 194 | // for Rest API calls. Use SetProxy() to specify proxy settings for Duo API calls. 195 | // 196 | // Example: duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second)) 197 | func NewDuoApi(ikey string, 198 | skey string, 199 | host string, 200 | userAgent string, 201 | options ...func(*apiOptions)) *DuoApi { 202 | opts := apiOptions{proxy: http.ProxyFromEnvironment} 203 | for _, o := range options { 204 | o(&opts) 205 | } 206 | 207 | // Certificate pinning 208 | certPool := x509.NewCertPool() 209 | certPool.AppendCertsFromPEM([]byte(duoPinnedCert)) 210 | 211 | tr := &http.Transport{ 212 | Proxy: opts.proxy, 213 | TLSClientConfig: &tls.Config{ 214 | RootCAs: certPool, 215 | InsecureSkipVerify: opts.insecure, 216 | }, 217 | } 218 | if opts.transport != nil { 219 | opts.transport(tr) 220 | } 221 | 222 | if userAgent != "" { 223 | userAgent += " " 224 | } 225 | userAgent += defaultUserAgent 226 | 227 | return &DuoApi{ 228 | ikey: ikey, 229 | skey: skey, 230 | host: host, 231 | userAgent: userAgent, 232 | apiClient: &http.Client{ 233 | Timeout: opts.timeout, 234 | Transport: tr, 235 | }, 236 | authClient: &http.Client{ 237 | Transport: tr, 238 | }, 239 | sleepSvc: timeSleepService{}, 240 | } 241 | } 242 | 243 | type requestOptions struct { 244 | timeout bool 245 | } 246 | 247 | type DuoApiOption func(*requestOptions) 248 | 249 | // Pass to Request or SignedRequest to configure a timeout on the request 250 | func UseTimeout(opts *requestOptions) { 251 | opts.timeout = true 252 | } 253 | 254 | func (duoapi *DuoApi) buildOptions(options ...DuoApiOption) *requestOptions { 255 | opts := &requestOptions{} 256 | for _, o := range options { 257 | o(opts) 258 | } 259 | return opts 260 | } 261 | 262 | type NullableInt32 struct { 263 | value *int32 264 | } 265 | 266 | // API calls will return a StatResult object. On success, Stat is 'OK'. 267 | // On error, Stat is 'FAIL', and Code, Message, and Message_Detail 268 | // contain error information. 269 | type StatResult struct { 270 | Stat string `json:"stat"` 271 | Ncode NullableInt32 `json:"code"` 272 | Code *int32 `json:"-"` 273 | Message *string `json:"message"` 274 | Message_Detail *string `json:"message_detail"` 275 | } 276 | 277 | func (n *NullableInt32) UnmarshalJSON(data []byte) error { 278 | var raw interface{} 279 | 280 | if err := json.Unmarshal(data, &raw); err != nil { 281 | return err 282 | } 283 | 284 | switch v := raw.(type) { 285 | case float64: 286 | intVal := int32(v) 287 | n.value = &intVal 288 | case string: 289 | intVal := int32(0) 290 | n.value = &intVal 291 | } 292 | return nil 293 | } 294 | 295 | func (s *StatResult) SyncCode() { 296 | s.Code = s.Ncode.value 297 | } 298 | 299 | // SetCustomHTTPClient allows one to set a completely custom http client that 300 | // will be used to make network calls to the duo api 301 | func (duoapi *DuoApi) SetCustomHTTPClient(c *http.Client) { 302 | duoapi.apiClient = c 303 | duoapi.authClient = c 304 | } 305 | 306 | // Make an unsigned Duo Rest API call. See Duo's online documentation 307 | // for the available REST API's. 308 | // method is POST or GET 309 | // uri is the URI of the Duo Rest call 310 | // params HTTP query parameters to include in the call. 311 | // options Optional parameters. Use UseTimeout to toggle whether the 312 | // Duo Rest API call should timeout or not. 313 | // 314 | // Example: duo.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout) 315 | func (duoapi *DuoApi) Call(method string, 316 | uri string, 317 | params url.Values, 318 | options ...DuoApiOption) (*http.Response, []byte, error) { 319 | 320 | url := url.URL{ 321 | Scheme: "https", 322 | Host: duoapi.host, 323 | Path: uri, 324 | RawQuery: params.Encode(), 325 | } 326 | headers := make(map[string]string) 327 | headers["User-Agent"] = duoapi.userAgent 328 | 329 | return duoapi.makeRetryableHttpCall(method, url, headers, nil, options...) 330 | } 331 | 332 | // Make a signed Duo Rest API call. See Duo's online documentation 333 | // for the available REST API's. 334 | // method is POST or GET 335 | // uri is the URI of the Duo Rest call 336 | // params HTTP query parameters to include in the call. 337 | // options Optional parameters. Use UseTimeout to toggle whether the 338 | // Duo Rest API call should timeout or not. 339 | // 340 | // Example: duo.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout) 341 | func (duoapi *DuoApi) SignedCall(method string, 342 | uri string, 343 | params url.Values, 344 | options ...DuoApiOption) (*http.Response, []byte, error) { 345 | 346 | now := time.Now().UTC().Format(time.RFC1123Z) 347 | auth_sig := sign(duoapi.ikey, duoapi.skey, method, duoapi.host, uri, now, params) 348 | 349 | url := url.URL{ 350 | Scheme: "https", 351 | Host: duoapi.host, 352 | Path: uri, 353 | } 354 | method = strings.ToUpper(method) 355 | 356 | if method == "GET" { 357 | url.RawQuery = params.Encode() 358 | } 359 | 360 | headers := make(map[string]string) 361 | headers["User-Agent"] = duoapi.userAgent 362 | headers["Authorization"] = auth_sig 363 | headers["Date"] = now 364 | var requestBody io.ReadCloser = nil 365 | if method == "POST" || method == "PUT" { 366 | headers["Content-Type"] = "application/x-www-form-urlencoded" 367 | requestBody = ioutil.NopCloser(strings.NewReader(params.Encode())) 368 | } 369 | 370 | return duoapi.makeRetryableHttpCall(method, url, headers, requestBody, options...) 371 | } 372 | 373 | type JSONParams map[string]interface{} 374 | 375 | // Make a signed Duo Rest API call that takes JSON as an argument. 376 | // See Duo's online documentation for the available REST API's. 377 | // method is one of GET, POST, PATCH, PUT, DELETE 378 | // uri is the URI of the Duo Rest call 379 | // json is the JSON parameters to include in the call. 380 | // options Optional parameters. Use UseTimeout to toggle whether the 381 | // Duo Rest API call should timeout or not. 382 | // 383 | // Example: 384 | // params := duoapi.JSONParams{ 385 | // "user_id": userid, 386 | // "activation_code": activationCode, 387 | // } 388 | // JSONSignedCall("POST", "/auth/v2/enroll_status", params, duoapi.UseTimeout) 389 | func (duoapi *DuoApi) JSONSignedCall(method string, 390 | uri string, 391 | params JSONParams, 392 | options ...DuoApiOption) (*http.Response, []byte, error) { 393 | 394 | body_methods := make(map[string]struct{}) 395 | body_methods["POST"] = struct{}{} 396 | body_methods["PUT"] = struct{}{} 397 | body_methods["PATCH"] = struct{}{} 398 | _, params_go_in_body := body_methods[method] 399 | 400 | now := time.Now().UTC().Format(time.RFC1123Z) 401 | var body string 402 | api_url := url.URL{ 403 | Scheme: "https", 404 | Host: duoapi.host, 405 | Path: uri, 406 | } 407 | 408 | url_values := url.Values{} 409 | if params_go_in_body { 410 | body_bytes, err := json.Marshal(params) 411 | if err != nil { 412 | return nil, nil, err 413 | } 414 | body = string(body_bytes[:]) 415 | } else { 416 | body = "" 417 | var err error 418 | url_values, err = jsonToValues(params) 419 | if err != nil { 420 | return nil, nil, err 421 | } 422 | api_url.RawQuery = url_values.Encode() 423 | } 424 | 425 | auth_sig := signV5(duoapi.ikey, duoapi.skey, method, duoapi.host, uri, now, url_values, body) 426 | 427 | method = strings.ToUpper(method) 428 | headers := make(map[string]string) 429 | headers["User-Agent"] = duoapi.userAgent 430 | headers["Authorization"] = auth_sig 431 | headers["Date"] = now 432 | var requestBody io.ReadCloser = nil 433 | if params_go_in_body { 434 | headers["Content-Type"] = "application/json" 435 | requestBody = ioutil.NopCloser(strings.NewReader(body)) 436 | } 437 | 438 | return duoapi.makeRetryableHttpCall(method, api_url, headers, requestBody, options...) 439 | } 440 | 441 | func (duoapi *DuoApi) makeRetryableHttpCall( 442 | method string, 443 | url url.URL, 444 | headers map[string]string, 445 | body io.ReadCloser, 446 | options ...DuoApiOption) (*http.Response, []byte, error) { 447 | 448 | opts := duoapi.buildOptions(options...) 449 | 450 | client := duoapi.authClient 451 | if opts.timeout { 452 | client = duoapi.apiClient 453 | } 454 | 455 | backoffMs := initialBackoffMS 456 | for { 457 | request, err := http.NewRequest(method, url.String(), nil) 458 | if err != nil { 459 | return nil, nil, err 460 | } 461 | 462 | if headers != nil { 463 | for k, v := range headers { 464 | request.Header.Set(k, v) 465 | } 466 | } 467 | if body != nil { 468 | request.Body = body 469 | } 470 | 471 | resp, err := client.Do(request) 472 | var body []byte 473 | if err != nil { 474 | return resp, body, err 475 | } 476 | 477 | if backoffMs > maxBackoffMS || resp.StatusCode != rateLimitHttpCode { 478 | body, err = ioutil.ReadAll(resp.Body) 479 | resp.Body.Close() 480 | return resp, body, err 481 | } 482 | 483 | resp.Body.Close() 484 | 485 | duoapi.sleepSvc.Sleep(time.Millisecond * time.Duration(backoffMs)) 486 | backoffMs *= backoffFactor 487 | } 488 | } 489 | 490 | const duoPinnedCert string = ` 491 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA1.cer 492 | # Certificate #1 Details: 493 | # Original Format: DER 494 | # Subject: CN=Amazon Root CA 1,O=Amazon,C=US 495 | # Issuer: CN=Amazon Root CA 1,O=Amazon,C=US 496 | # Expiration Date: 2038-01-17 00:00:00 497 | # Serial Number: 66C9FCF99BF8C0A39E2F0788A43E696365BCA 498 | # SHA256 Fingerprint: 8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e 499 | 500 | -----BEGIN CERTIFICATE----- 501 | MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF 502 | ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 503 | b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL 504 | MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv 505 | b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj 506 | ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM 507 | 9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw 508 | IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 509 | VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L 510 | 93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm 511 | jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC 512 | AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA 513 | A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI 514 | U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs 515 | N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv 516 | o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU 517 | 5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy 518 | rqXRfboQnoZsG4q5WTP468SQvvG5 519 | -----END CERTIFICATE----- 520 | 521 | 522 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA2.cer 523 | # Certificate #1 Details: 524 | # Original Format: DER 525 | # Subject: CN=Amazon Root CA 2,O=Amazon,C=US 526 | # Issuer: CN=Amazon Root CA 2,O=Amazon,C=US 527 | # Expiration Date: 2040-05-26 00:00:00 528 | # Serial Number: 66C9FD29635869F0A0FE58678F85B26BB8A37 529 | # SHA256 Fingerprint: 1ba5b2aa8c65401a82960118f80bec4f62304d83cec4713a19c39c011ea46db4 530 | 531 | -----BEGIN CERTIFICATE----- 532 | MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF 533 | ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 534 | b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL 535 | MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv 536 | b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK 537 | gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ 538 | W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg 539 | 1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K 540 | 8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r 541 | 2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me 542 | z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR 543 | 8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj 544 | mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz 545 | 7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 546 | +XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI 547 | 0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB 548 | Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm 549 | UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 550 | LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY 551 | +gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS 552 | k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl 553 | 7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm 554 | btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl 555 | urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ 556 | fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 557 | n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE 558 | 76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H 559 | 9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT 560 | 4PsJYGw= 561 | -----END CERTIFICATE----- 562 | 563 | 564 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA3.cer 565 | # Certificate #1 Details: 566 | # Original Format: DER 567 | # Subject: CN=Amazon Root CA 3,O=Amazon,C=US 568 | # Issuer: CN=Amazon Root CA 3,O=Amazon,C=US 569 | # Expiration Date: 2040-05-26 00:00:00 570 | # Serial Number: 66C9FD5749736663F3B0B9AD9E89E7603F24A 571 | # SHA256 Fingerprint: 18ce6cfe7bf14e60b2e347b8dfe868cb31d02ebb3ada271569f50343b46db3a4 572 | 573 | -----BEGIN CERTIFICATE----- 574 | MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 575 | MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g 576 | Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG 577 | A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg 578 | Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl 579 | ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j 580 | QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr 581 | ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr 582 | BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM 583 | YyRIHN8wfdVoOw== 584 | -----END CERTIFICATE----- 585 | 586 | 587 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA4.cer 588 | # Certificate #1 Details: 589 | # Original Format: DER 590 | # Subject: CN=Amazon Root CA 4,O=Amazon,C=US 591 | # Issuer: CN=Amazon Root CA 4,O=Amazon,C=US 592 | # Expiration Date: 2040-05-26 00:00:00 593 | # Serial Number: 66C9FD7C1BB104C2943E5717B7B2CC81AC10E 594 | # SHA256 Fingerprint: e35d28419ed02025cfa69038cd623962458da5c695fbdea3c22b0bfb25897092 595 | 596 | -----BEGIN CERTIFICATE----- 597 | MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 598 | MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g 599 | Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG 600 | A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg 601 | Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi 602 | 9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk 603 | M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB 604 | /zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB 605 | MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw 606 | CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW 607 | 1KyLa2tJElMzrdfkviT8tQp21KW8EA== 608 | -----END CERTIFICATE----- 609 | 610 | 611 | # Source URL: https://www.amazontrust.com/repository/SFSRootCAG2.cer 612 | # Certificate #1 Details: 613 | # Original Format: DER 614 | # Subject: CN=Starfield Services Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 615 | # Issuer: CN=Starfield Services Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 616 | # Expiration Date: 2037-12-31 23:59:59 617 | # Serial Number: 0 618 | # SHA256 Fingerprint: 568d6905a2c88708a4b3025190edcfedb1974a606a13c6e5290fcb2ae63edab5 619 | 620 | -----BEGIN CERTIFICATE----- 621 | MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx 622 | EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT 623 | HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs 624 | ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 625 | MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD 626 | VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy 627 | ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy 628 | dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI 629 | hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p 630 | OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2 631 | 8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K 632 | Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe 633 | hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk 634 | 6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw 635 | DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q 636 | AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI 637 | bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB 638 | ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z 639 | qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd 640 | iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn 641 | 0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN 642 | sSi6 643 | -----END CERTIFICATE----- 644 | 645 | 646 | # Source URL: https://cacerts.digicert.com/DigiCertHighAssuranceEVRootCA.crt 647 | # Certificate #1 Details: 648 | # Original Format: DER 649 | # Subject: CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US 650 | # Issuer: CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US 651 | # Expiration Date: 2031-11-10 00:00:00 652 | # Serial Number: 2AC5C266A0B409B8F0B79F2AE462577 653 | # SHA256 Fingerprint: 7431e5f4c3c1ce4690774f0b61e05440883ba9a01ed00ba6abd7806ed3b118cf 654 | 655 | -----BEGIN CERTIFICATE----- 656 | MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs 657 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 658 | d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j 659 | ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL 660 | MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 661 | LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug 662 | RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm 663 | +9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW 664 | PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM 665 | xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB 666 | Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 667 | hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg 668 | EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF 669 | MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA 670 | FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec 671 | nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z 672 | eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF 673 | hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 674 | Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe 675 | vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep 676 | +OkuE6N36B9K 677 | -----END CERTIFICATE----- 678 | 679 | 680 | # Source URL: https://cacerts.digicert.com/DigiCertTLSECCP384RootG5.crt 681 | # Certificate #1 Details: 682 | # Original Format: DER 683 | # Subject: CN=DigiCert TLS ECC P384 Root G5,O=DigiCert\, Inc.,C=US 684 | # Issuer: CN=DigiCert TLS ECC P384 Root G5,O=DigiCert\, Inc.,C=US 685 | # Expiration Date: 2046-01-14 23:59:59 686 | # Serial Number: 9E09365ACF7D9C8B93E1C0B042A2EF3 687 | # SHA256 Fingerprint: 018e13f0772532cf809bd1b17281867283fc48c6e13be9c69812854a490c1b05 688 | 689 | -----BEGIN CERTIFICATE----- 690 | MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQsw 691 | CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURp 692 | Z2lDZXJ0IFRMUyBFQ0MgUDM4NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2 693 | MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ 694 | bmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQgUm9vdCBHNTB2MBAG 695 | ByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1TzvdlHJS 696 | 7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp 697 | 0zVozptjn4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICIS 698 | B4CIfBFqMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 699 | BAMDA2gAMGUCMQCJao1H5+z8blUD2WdsJk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQ 700 | LgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIxAJSdYsiJvRmEFOml+wG4 701 | DXZDjC5Ty3zfDBeWUA== 702 | -----END CERTIFICATE----- 703 | 704 | 705 | # Source URL: https://cacerts.digicert.com/DigiCertTLSRSA4096RootG5.crt 706 | # Certificate #1 Details: 707 | # Original Format: DER 708 | # Subject: CN=DigiCert TLS RSA4096 Root G5,O=DigiCert\, Inc.,C=US 709 | # Issuer: CN=DigiCert TLS RSA4096 Root G5,O=DigiCert\, Inc.,C=US 710 | # Expiration Date: 2046-01-14 23:59:59 711 | # Serial Number: 8F9B478A8FA7EDA6A333789DE7CCF8A 712 | # SHA256 Fingerprint: 371a00dc0533b3721a7eeb40e8419e70799d2b0a0f2c1d80693165f7cec4ad75 713 | 714 | -----BEGIN CERTIFICATE----- 715 | MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBN 716 | MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMT 717 | HERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN 718 | NDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs 719 | IEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwggIi 720 | MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS87IE+ 721 | ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG0 722 | 2C+JFvuUAT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgp 723 | wgscONyfMXdcvyej/Cestyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZM 724 | pG2T6T867jp8nVid9E6P/DsjyG244gXazOvswzH016cpVIDPRFtMbzCe88zdH5RD 725 | nU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnVDdXifBBiqmvwPXbzP6Po 726 | sMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9qTXeXAaDx 727 | Zre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cd 728 | Lvvyz6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvX 729 | KyY//SovcfXWJL5/MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNe 730 | XoVPzthwiHvOAbWWl9fNff2C+MIkwcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPL 731 | tgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4EFgQUUTMc7TZArxfTJc1paPKv 732 | TiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcN 733 | AQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw 734 | GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7H 735 | PNtQOa27PShNlnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLF 736 | O4uJ+DQtpBflF+aZfTCIITfNMBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQ 737 | REtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/u4cnYiWB39yhL/btp/96j1EuMPik 738 | AdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9GOUrYU9DzLjtxpdRv 739 | /PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh47a+ 740 | p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilw 741 | MUc/dNAUFvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WF 742 | qUITVuwhd4GTWgzqltlJyqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCK 743 | ovfepEWFJqgejF0pW8hL2JpqA15w8oVPbEtoL8pU9ozaMv7Da4M/OMZ+ 744 | -----END CERTIFICATE----- 745 | 746 | 747 | # Source URL: https://secure.globalsign.com/cacert/rootr46.crt 748 | # Certificate #1 Details: 749 | # Original Format: DER 750 | # Subject: CN=GlobalSign Root R46,O=GlobalSign nv-sa,C=BE 751 | # Issuer: CN=GlobalSign Root R46,O=GlobalSign nv-sa,C=BE 752 | # Expiration Date: 2046-03-20 00:00:00 753 | # Serial Number: 11D2BBB9D723189E405F0A9D2DD0DF2567D1 754 | # SHA256 Fingerprint: 4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9 755 | 756 | -----BEGIN CERTIFICATE----- 757 | MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUA 758 | MEYxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYD 759 | VQQDExNHbG9iYWxTaWduIFJvb3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMy 760 | MDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt 761 | c2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB 762 | AQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08EsCVeJ 763 | OaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQG 764 | vGIFAha/r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud 765 | 316HCkD7rRlr+/fKYIje2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo 766 | 0q3v84RLHIf8E6M6cqJaESvWJ3En7YEtbWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSE 767 | y132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvjK8Cd+RTyG/FWaha/LIWF 768 | zXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD412lPFzYE 769 | +cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCN 770 | I/onccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzs 771 | x2sZy/N78CsHpdlseVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqa 772 | ByFrgY/bxFn63iLABJzjqls2k+g9vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC 773 | 4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV 774 | HQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEMBQADggIBAHx4 775 | 7PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg 776 | JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti 777 | 2kM3S+LGteWygxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIk 778 | pnnpHs6i58FZFZ8d4kuaPp92CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRF 779 | FRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZmOUdkLG5NrmJ7v2B0GbhWrJKsFjLt 780 | rWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qqJZ4d16GLuc1CLgSk 781 | ZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwyeqiv5 782 | u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP 783 | 4vkYxboznxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6 784 | N3ec592kD3ZDZopD8p/7DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3 785 | vouXsXgxT7PntgMTzlSdriVZzH81Xwj3QEUxeCp6 786 | -----END CERTIFICATE----- 787 | 788 | 789 | # Source URL: https://secure.globalsign.com/cacert/roote46.crt 790 | # Certificate #1 Details: 791 | # Original Format: DER 792 | # Subject: CN=GlobalSign Root E46,O=GlobalSign nv-sa,C=BE 793 | # Issuer: CN=GlobalSign Root E46,O=GlobalSign nv-sa,C=BE 794 | # Expiration Date: 2046-03-20 00:00:00 795 | # Serial Number: 11D2BBBA336ED4BCE62468C50D841D98E843 796 | # SHA256 Fingerprint: cbb9c44d84b8043e1050ea31a69f514955d7bfd2e2c6b49301019ad61d9f5058 797 | 798 | -----BEGIN CERTIFICATE----- 799 | MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx 800 | CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD 801 | ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw 802 | MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex 803 | HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA 804 | IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq 805 | R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd 806 | yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud 807 | DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ 808 | 7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 809 | +RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= 810 | -----END CERTIFICATE----- 811 | 812 | 813 | # Source URL: https://i.pki.goog/r2.crt 814 | # Certificate #1 Details: 815 | # Original Format: DER 816 | # Subject: CN=GTS Root R2,O=Google Trust Services LLC,C=US 817 | # Issuer: CN=GTS Root R2,O=Google Trust Services LLC,C=US 818 | # Expiration Date: 2036-06-22 00:00:00 819 | # Serial Number: 203E5AEC58D04251AAB1125AA 820 | # SHA256 Fingerprint: 8d25cd97229dbf70356bda4eb3cc734031e24cf00fafcfd32dc76eb5841c7ea8 821 | 822 | -----BEGIN CERTIFICATE----- 823 | MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw 824 | CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU 825 | MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw 826 | MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp 827 | Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA 828 | A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt 829 | nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY 830 | 6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu 831 | MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k 832 | RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg 833 | f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV 834 | +3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo 835 | dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW 836 | Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa 837 | G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq 838 | gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID 839 | AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E 840 | FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H 841 | vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 842 | 0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC 843 | B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u 844 | NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg 845 | yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev 846 | HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6 847 | xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR 848 | TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg 849 | JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV 850 | 7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl 851 | 6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL 852 | -----END CERTIFICATE----- 853 | 854 | 855 | # Source URL: https://i.pki.goog/r4.crt 856 | # Certificate #1 Details: 857 | # Original Format: DER 858 | # Subject: CN=GTS Root R4,O=Google Trust Services LLC,C=US 859 | # Issuer: CN=GTS Root R4,O=Google Trust Services LLC,C=US 860 | # Expiration Date: 2036-06-22 00:00:00 861 | # Serial Number: 203E5C068EF631A9C72905052 862 | # SHA256 Fingerprint: 349dfa4058c5e263123b398ae795573c4e1313c83fe68f93556cd5e8031b3c7d 863 | 864 | -----BEGIN CERTIFICATE----- 865 | MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD 866 | VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG 867 | A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw 868 | WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz 869 | IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi 870 | AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi 871 | QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR 872 | HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW 873 | BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D 874 | 9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 875 | p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD 876 | -----END CERTIFICATE----- 877 | 878 | 879 | # Source URL: https://www.identrust.com/file-download/download/public/5718 880 | # Certificate #1 Details: 881 | # Original Format: PKCS7-DER 882 | # Subject: CN=IdenTrust Commercial Root CA 1,O=IdenTrust,C=US 883 | # Issuer: CN=IdenTrust Commercial Root CA 1,O=IdenTrust,C=US 884 | # Expiration Date: 2034-01-16 18:12:23 885 | # Serial Number: A0142800000014523C844B500000002 886 | # SHA256 Fingerprint: 5d56499be4d2e08bcfcad08a3e38723d50503bde706948e42f55603019e528ae 887 | 888 | -----BEGIN CERTIFICATE----- 889 | MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBK 890 | MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVu 891 | VHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQw 892 | MTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScw 893 | JQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwggIiMA0GCSqG 894 | SIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ldhNlT 895 | 3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU 896 | +ehcCuz/mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gp 897 | S0l4PJNgiCL8mdo2yMKi1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1 898 | bVoE/c40yiTcdCMbXTMTEl3EASX2MN0CXZ/g1Ue9tOsbobtJSdifWwLziuQkkORi 899 | T0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl3ZBWzvurpWCdxJ35UrCL 900 | vYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzyNeVJSQjK 901 | Vsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZK 902 | dHzVWYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHT 903 | c+XvvqDtMwt0viAgxGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hv 904 | l7yTmvmcEpB4eoCHFddydJxVdHixuuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5N 905 | iGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB 906 | /zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZIhvcNAQELBQAD 907 | ggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH 908 | 6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwt 909 | LRvM7Kqas6pgghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93 910 | nAbowacYXVKV7cndJZ5t+qntozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3 911 | +wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmVYjzlVYA211QC//G5Xc7UI2/YRYRK 912 | W2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUXfeu+h1sXIFRRk0pT 913 | AwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/rokTLq 914 | l1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG 915 | 4iZZRHUe2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZ 916 | mUlO+KWA2yUPHGNiiskzZ2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A 917 | 7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7RcGzM7vRX+Bi6hG6H 918 | -----END CERTIFICATE----- 919 | 920 | 921 | # Source URL: https://www.identrust.com/file-download/download/public/5842 922 | # Certificate #1 Details: 923 | # Original Format: PKCS7-PEM 924 | # Subject: CN=IdenTrust Commercial Root TLS ECC CA 2,O=IdenTrust,C=US 925 | # Issuer: CN=IdenTrust Commercial Root TLS ECC CA 2,O=IdenTrust,C=US 926 | # Expiration Date: 2039-04-11 21:11:10 927 | # Serial Number: 40018ECF000DE911D7447B73E4C1F82E 928 | # SHA256 Fingerprint: 983d826ba9c87f653ff9e8384c5413e1d59acf19ddc9c98cecae5fdea2ac229c 929 | 930 | -----BEGIN CERTIFICATE----- 931 | MIICbDCCAc2gAwIBAgIQQAGOzwAN6RHXRHtz5MH4LjAKBggqhkjOPQQDBDBSMQsw 932 | CQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MS8wLQYDVQQDEyZJZGVuVHJ1 933 | c3QgQ29tbWVyY2lhbCBSb290IFRMUyBFQ0MgQ0EgMjAeFw0yNDA0MTEyMTExMTFa 934 | Fw0zOTA0MTEyMTExMTBaMFIxCzAJBgNVBAYTAlVTMRIwEAYDVQQKEwlJZGVuVHJ1 935 | c3QxLzAtBgNVBAMTJklkZW5UcnVzdCBDb21tZXJjaWFsIFJvb3QgVExTIEVDQyBD 936 | QSAyMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBwomiZTgLg8KqEImMmnO5rNPb 937 | Oo9sv5w4nJh45CXs9Gcu8YET9ulxsyVBCVSfSYeppdtXFEWYyBi0QRCAlp5YZHQB 938 | H675v5rWVKRXvhzsuUNi9Xw0Zy1bAXaikmsrY/J0L52j2RulW4q4WvE7f23VFwZu 939 | d82J8k0YG+M4MpmdOho1rsKjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ 940 | BAQDAgGGMB0GA1UdDgQWBBQhNGgGrnXhVx/FuQqjXpuH+IlbwzAKBggqhkjOPQQD 941 | BAOBjAAwgYgCQgDc9F4WOxAgci2uQWfsX9cjeIvDXaaeVjDz31Ycc+ZdPrK1JKrB 942 | f6CuTwWy8VojtGxdM3PJMkJC4LGPuhcvkHLo4gJCAV5h+PXe4bDJ3QxE8hkGFoUW 943 | Ak6KtMCIpbLyt5pHrROi+YW9MpScoNGJkg96G1ETvJTWz6dv0uQYjKXt3jlOfQ7g 944 | -----END CERTIFICATE----- 945 | 946 | 947 | # Source URL: https://ssl-ccp.secureserver.net/repository/sfroot-g2.crt 948 | # Certificate #1 Details: 949 | # Original Format: PEM 950 | # Subject: CN=Starfield Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 951 | # Issuer: CN=Starfield Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 952 | # Expiration Date: 2037-12-31 23:59:59 953 | # Serial Number: 0 954 | # SHA256 Fingerprint: 2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5 955 | 956 | -----BEGIN CERTIFICATE----- 957 | MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx 958 | EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT 959 | HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs 960 | ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw 961 | MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 962 | b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj 963 | aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp 964 | Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 965 | ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg 966 | nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 967 | HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N 968 | Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN 969 | dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 970 | HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO 971 | BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G 972 | CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU 973 | sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 974 | 4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg 975 | 8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K 976 | pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 977 | mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 978 | -----END CERTIFICATE-----` 979 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/duosecurity/duo_api_golang 2 | 3 | go 1.15 -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosecurity/duo_api_golang/ac36954387e76a8c8219cd1f5bfbd900443a849a/go.sum --------------------------------------------------------------------------------