auth/okta: Add support for Okta number challenge (#15361)

* POC of Okta Auth Number Challenge verification

* switch from callbacks to operations, forward validate to primary

* cleanup and nonce description update

* add changelog

* error on empty nonce, no forwarding, return correct_answer instead

* properly clean up verify goroutine

* add docs on new endpoint and parameters

* change polling frequency when WAITING to 1s

Co-authored-by: Jim Kalafut <jkalafut@hashicorp.com>
This commit is contained in:
Calvin Leung Huang 2022-05-11 17:09:29 -07:00 committed by GitHub
parent da7fe65aee
commit ca44b5a3e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 146 additions and 8 deletions

View File

@ -11,6 +11,7 @@ import (
"github.com/hashicorp/vault/sdk/helper/cidrutil" "github.com/hashicorp/vault/sdk/helper/cidrutil"
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
"github.com/okta/okta-sdk-golang/v2/okta" "github.com/okta/okta-sdk-golang/v2/okta"
"github.com/patrickmn/go-cache"
) )
const ( const (
@ -34,6 +35,7 @@ func Backend() *backend {
PathsSpecial: &logical.Paths{ PathsSpecial: &logical.Paths{
Unauthenticated: []string{ Unauthenticated: []string{
"login/*", "login/*",
"verify/*",
}, },
SealWrapStorage: []string{ SealWrapStorage: []string{
"config", "config",
@ -47,20 +49,23 @@ func Backend() *backend {
pathUsersList(&b), pathUsersList(&b),
pathGroupsList(&b), pathGroupsList(&b),
pathLogin(&b), pathLogin(&b),
pathVerify(&b),
}, },
AuthRenew: b.pathLoginRenew, AuthRenew: b.pathLoginRenew,
BackendType: logical.TypeCredential, BackendType: logical.TypeCredential,
} }
b.verifyCache = cache.New(5*time.Minute, time.Minute)
return &b return &b
} }
type backend struct { type backend struct {
*framework.Backend *framework.Backend
verifyCache *cache.Cache
} }
func (b *backend) Login(ctx context.Context, req *logical.Request, username, password, totp, preferredProvider string) ([]string, *logical.Response, []string, error) { func (b *backend) Login(ctx context.Context, req *logical.Request, username, password, totp, nonce, preferredProvider string) ([]string, *logical.Response, []string, error) {
cfg, err := b.Config(ctx, req.Storage) cfg, err := b.Config(ctx, req.Storage)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
@ -89,11 +94,17 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username, pas
Id string `json:"id"` Id string `json:"id"`
Type string `json:"factorType"` Type string `json:"factorType"`
Provider string `json:"provider"` Provider string `json:"provider"`
Embedded struct {
Challenge struct {
CorrectAnswer *int `json:"correctAnswer"`
} `json:"challenge"`
} `json:"_embedded"`
} }
type embeddedResult struct { type embeddedResult struct {
User okta.User `json:"user"` User okta.User `json:"user"`
Factors []mfaFactor `json:"factors"` Factors []mfaFactor `json:"factors"`
Factor *mfaFactor `json:"factor"`
} }
type authResult struct { type authResult struct {
@ -238,6 +249,17 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username, pas
return nil, logical.ErrorResponse(fmt.Sprintf("okta auth failed creating verify request: %v", err)), nil, nil return nil, logical.ErrorResponse(fmt.Sprintf("okta auth failed creating verify request: %v", err)), nil, nil
} }
rsp, err := shim.Do(verifyReq, &result) rsp, err := shim.Do(verifyReq, &result)
// Store number challenge if found
numberChallenge := result.Embedded.Factor.Embedded.Challenge.CorrectAnswer
if numberChallenge != nil {
if nonce == "" {
return nil, logical.ErrorResponse("nonce must be provided during login request when presented with number challenge"), nil, nil
}
b.verifyCache.SetDefault(nonce, *numberChallenge)
}
if err != nil { if err != nil {
return nil, logical.ErrorResponse(fmt.Sprintf("Okta auth failed checking loop: %v", err)), nil, nil return nil, logical.ErrorResponse(fmt.Sprintf("Okta auth failed checking loop: %v", err)), nil, nil
} }
@ -246,7 +268,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username, pas
} }
select { select {
case <-time.After(500 * time.Millisecond): case <-time.After(1 * time.Second):
// Continue // Continue
case <-ctx.Done(): case <-ctx.Done():
return nil, logical.ErrorResponse("exiting pending mfa challenge"), nil, nil return nil, logical.ErrorResponse("exiting pending mfa challenge"), nil, nil

View File

@ -1,10 +1,13 @@
package okta package okta
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"github.com/hashicorp/go-secure-stdlib/base62"
pwd "github.com/hashicorp/go-secure-stdlib/password" pwd "github.com/hashicorp/go-secure-stdlib/password"
"github.com/hashicorp/vault/api" "github.com/hashicorp/vault/api"
) )
@ -48,6 +51,30 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (*api.Secret, erro
data["provider"] = provider data["provider"] = provider
} }
nonce := base62.MustRandom(20)
data["nonce"] = nonce
// Create a done channel to signal termination of the login so that we can
// clean up the goroutine
doneCh := make(chan struct{})
defer close(doneCh)
go func() {
for {
select {
case <-doneCh:
return
case <-time.After(time.Second):
}
resp, _ := c.Logical().Read(fmt.Sprintf("auth/%s/verify/%s", mount, nonce))
if resp != nil {
fmt.Fprintf(os.Stderr, "In Okta Verify, tap the number %q\n", resp.Data["correct_answer"].(json.Number))
return
}
}
}()
path := fmt.Sprintf("auth/%s/login/%s", mount, username) path := fmt.Sprintf("auth/%s/login/%s", mount, username)
secret, err := c.Logical().Write(path, data) secret, err := c.Logical().Write(path, data)
if err != nil { if err != nil {

View File

@ -34,6 +34,12 @@ func pathLogin(b *backend) *framework.Path {
Type: framework.TypeString, Type: framework.TypeString,
Description: "TOTP passcode.", Description: "TOTP passcode.",
}, },
"nonce": {
Type: framework.TypeString,
Description: `Nonce provided if performing login that requires
number verification challenge. Logins through the vault login CLI command will
automatically generate a nonce.`,
},
"provider": { "provider": {
Type: framework.TypeString, Type: framework.TypeString,
Description: "Preferred factor provider.", Description: "Preferred factor provider.",
@ -73,12 +79,15 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, d *framew
username := d.Get("username").(string) username := d.Get("username").(string)
password := d.Get("password").(string) password := d.Get("password").(string)
totp := d.Get("totp").(string) totp := d.Get("totp").(string)
nonce := d.Get("nonce").(string)
preferredProvider := strings.ToUpper(d.Get("provider").(string)) preferredProvider := strings.ToUpper(d.Get("provider").(string))
if preferredProvider != "" && !strutil.StrListContains(b.getSupportedProviders(), preferredProvider) { if preferredProvider != "" && !strutil.StrListContains(b.getSupportedProviders(), preferredProvider) {
return logical.ErrorResponse(fmt.Sprintf("provider %s is not among the supported ones %v", preferredProvider, b.getSupportedProviders())), nil return logical.ErrorResponse(fmt.Sprintf("provider %s is not among the supported ones %v", preferredProvider, b.getSupportedProviders())), nil
} }
policies, resp, groupNames, err := b.Login(ctx, req, username, password, totp, preferredProvider) defer b.verifyCache.Delete(nonce)
policies, resp, groupNames, err := b.Login(ctx, req, username, password, totp, nonce, preferredProvider)
// Handle an internal error // Handle an internal error
if err != nil { if err != nil {
return nil, err return nil, err
@ -134,6 +143,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, d *framew
func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
username := req.Auth.Metadata["username"] username := req.Auth.Metadata["username"]
password := req.Auth.InternalData["password"].(string) password := req.Auth.InternalData["password"].(string)
nonce := d.Get("nonce").(string)
cfg, err := b.getConfig(ctx, req) cfg, err := b.getConfig(ctx, req)
if err != nil { if err != nil {
@ -142,7 +152,7 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f
// No TOTP entry is possible on renew. If push MFA is enabled it will still be triggered, however. // No TOTP entry is possible on renew. If push MFA is enabled it will still be triggered, however.
// Sending "" as the totp will prompt the push action if it is configured. // Sending "" as the totp will prompt the push action if it is configured.
loginPolicies, resp, groupNames, err := b.Login(ctx, req, username, password, "", "") loginPolicies, resp, groupNames, err := b.Login(ctx, req, username, password, "", nonce, "")
if err != nil || (resp != nil && resp.IsError()) { if err != nil || (resp != nil && resp.IsError()) {
return resp, err return resp, err
} }
@ -172,6 +182,41 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f
return resp, nil return resp, nil
} }
func pathVerify(b *backend) *framework.Path {
return &framework.Path{
Pattern: `verify/(?P<nonce>.+)`,
Fields: map[string]*framework.FieldSchema{
"nonce": {
Type: framework.TypeString,
Description: `Nonce provided during a login request to
retrieve the number verification challenge for the matching request.`,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.pathVerify,
},
},
}
}
func (b *backend) pathVerify(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
nonce := d.Get("nonce").(string)
correctRaw, ok := b.verifyCache.Get(nonce)
if !ok {
return nil, nil
}
resp := &logical.Response{
Data: map[string]interface{}{
"correct_answer": correctRaw.(int),
},
}
return resp, nil
}
func (b *backend) getConfig(ctx context.Context, req *logical.Request) (*ConfigEntry, error) { func (b *backend) getConfig(ctx context.Context, req *logical.Request) (*ConfigEntry, error) {
cfg, err := b.Config(ctx, req.Storage) cfg, err := b.Config(ctx, req.Storage)
if err != nil { if err != nil {

5
changelog/15361.txt Normal file
View File

@ -0,0 +1,5 @@
```release-note:improvement
auth/okta: Add support for performing [the number
challenge](https://help.okta.com/en-us/Content/Topics/Mobile/ov-admin-config.htm?cshid=csh-okta-verify-number-challenge-v1#enable-number-challenge)
during an Okta Verify push challenge
```

View File

@ -353,6 +353,8 @@ Login with the username and password.
- `password` `(string: <required>)` - Password for the authenticating user. - `password` `(string: <required>)` - Password for the authenticating user.
- `totp` `(string: <optional>)` - Okta Verify TOTP passcode. - `totp` `(string: <optional>)` - Okta Verify TOTP passcode.
- `provider` `(string: <optional>)` - MFA TOTP factor provider. `GOOGLE` and `OKTA` are currently supported. - `provider` `(string: <optional>)` - MFA TOTP factor provider. `GOOGLE` and `OKTA` are currently supported.
- `nonce` `(string: <optional>)` - Nonce provided during a login request to
retrieve the number verification challenge for the matching request.
### Sample Payload ### Sample Payload
@ -373,7 +375,7 @@ $ curl \
### Sample Response ### Sample Response
```javascript ```json
{ {
"lease_id": "", "lease_id": "",
"renewable": false, "renewable": false,
@ -388,8 +390,45 @@ $ curl \
"username": "fred", "username": "fred",
"policies": "default" "policies": "default"
}, },
}, "lease_duration": 7200,
"lease_duration": 7200, "renewable": true
"renewable": true }
} }
``` ```
## Verify
Verify a number challenge that may result from an Okta Verify Push challenge.
| Method | Path |
| :----- | :--------------------------- |
| `GET` | `/auth/okta/verify/:nonce` |
### Parameters
- `nonce` `(string: <required>)` - Nonce provided if performing login that
requires number verification challenge. Logins through the vault login CLI
command will automatically generate a nonce.
### Sample Request
```shell-session
$ curl \
http://127.0.0.1:8200/v1/auth/okta/nonce/BCR66Ru6oJKPtC00PxJJ
```
### Sample Response
```json
{
"request_id": "de6a8029-79e5-1dd1-dbe9-6405166b3f94",
"lease_id": "",
"lease_duration": 0,
"renewable": false,
"data": {
"correct_answer": 94
},
"warnings": null
}
```