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:
parent
da7fe65aee
commit
ca44b5a3e0
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
```
|
|
@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue