From b581bf20e077004b6b8f0c2aafd8b9025e682df5 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 11:23:34 -0700 Subject: [PATCH 01/12] mfa: add MFA wrapper with Duo second factor --- helper/mfa/duo/duo.go | 101 +++++++++++++++++++++++++++++ helper/mfa/duo/path_duo_access.go | 103 ++++++++++++++++++++++++++++++ helper/mfa/duo/path_duo_config.go | 103 ++++++++++++++++++++++++++++++ helper/mfa/mfa.go | 59 +++++++++++++++++ helper/mfa/path_mfa_config.go | 88 +++++++++++++++++++++++++ 5 files changed, 454 insertions(+) create mode 100644 helper/mfa/duo/duo.go create mode 100644 helper/mfa/duo/path_duo_access.go create mode 100644 helper/mfa/duo/path_duo_config.go create mode 100644 helper/mfa/mfa.go create mode 100644 helper/mfa/path_mfa_config.go diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go new file mode 100644 index 0000000000..7f2824eab9 --- /dev/null +++ b/helper/mfa/duo/duo.go @@ -0,0 +1,101 @@ +package duo + +import ( + "fmt" + "net/url" + + "github.com/duosecurity/duo_api_golang/authapi" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func DuoPaths() []*framework.Path { + return []*framework.Path{ + pathDuoConfig(), + pathDuoAccess(), + } +} + +func DuoPathsSpecial() []string { + return []string { + "duo/access", + "duo/config", + } +} + +func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Response) ( + *logical.Response, error) { + duo_config, err := GetDuoConfig(req) + if err != nil || duo_config == nil { + return logical.ErrorResponse("Could not load Duo configuration"), nil + } + + duo_auth_client, err := GetDuoAuthClient(req, duo_config) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + username, ok := resp.Auth.Metadata["username"] + if !ok { + return logical.ErrorResponse("Could not read username for MFA"), nil + } + + duo_user := fmt.Sprintf(duo_config.UsernameFormat, username) + + preauth, err := duo_auth_client.Preauth( + authapi.PreauthUsername(duo_user), + authapi.PreauthIpAddr(req.Connection.RemoteAddr), + ) + + if preauth.StatResult.Stat != "OK" { + return logical.ErrorResponse(fmt.Sprintf("Could not look up Duo user information: %s (%s)", + *preauth.StatResult.Message, + *preauth.StatResult.Message_Detail, + )), nil + } + + switch preauth.Response.Result { + case "allow": + return resp, err + case "deny": + return logical.ErrorResponse(preauth.Response.Status_Msg), nil + case "enroll": + return logical.ErrorResponse(preauth.Response.Status_Msg), nil + case "auth": + break + } + + options := []func(*url.Values){authapi.AuthUsername(duo_user)} + + method := d.Get("method").(string) + if method == "" { + method = "auto" + } + + passcode := d.Get("passcode").(string) + if passcode != "" { + method = "passcode" + options = append(options, authapi.AuthPasscode(passcode)) + } else { + options = append(options, authapi.AuthDevice("auto")) + } + + result, err := duo_auth_client.Auth(method, options...) + + if err != nil { + return logical.ErrorResponse("Could not call Duo auth"), nil + } + + if result.StatResult.Stat != "OK" { + return logical.ErrorResponse(fmt.Sprintf("Could not authenticate Duo user: %s (%s)", + *preauth.StatResult.Message, + *preauth.StatResult.Message_Detail, + )), nil + } + + if result.Response.Result != "allow" { + return logical.ErrorResponse(result.Response.Status_Msg), nil + } + + return resp, err +} diff --git a/helper/mfa/duo/path_duo_access.go b/helper/mfa/duo/path_duo_access.go new file mode 100644 index 0000000000..271ea996cc --- /dev/null +++ b/helper/mfa/duo/path_duo_access.go @@ -0,0 +1,103 @@ +package duo + +import ( + "fmt" + + "github.com/duosecurity/duo_api_golang" + "github.com/duosecurity/duo_api_golang/authapi" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathDuoAccess() *framework.Path { + return &framework.Path{ + Pattern: `duo/access`, + Fields: map[string]*framework.FieldSchema{ + "skey": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Duo secret key", + }, + "ikey": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Duo integration key", + }, + "host": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Duo api host", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.WriteOperation: pathDuoAccessWrite, + }, + + HelpSynopsis: pathDuoAccessHelpSyn, + HelpDescription: pathDuoAccessHelpDesc, + } +} + +func GetDuoAuthClient(req *logical.Request, config *DuoConfig) (*authapi.AuthApi, error) { + entry, err := req.Storage.Get("duo/access") + if err != nil { + return nil, err + } + if entry == nil { + return nil, fmt.Errorf( + "Duo access credentials haven't been configured. Please configure\n" + + "them at the 'duo/access' endpoint") + } + var access DuoAccess + if err := entry.DecodeJSON(&access); err != nil { + return nil, err + } + + duo_client := duoapi.NewDuoApi( + access.IKey, + access.SKey, + access.Host, + config.UserAgent, + ) + duo_auth_client := authapi.NewAuthApi(*duo_client) + check, err := duo_auth_client.Check() + if err != nil { + return nil, err + } + if check.StatResult.Stat != "OK" { + return nil, fmt.Errorf("Could not connect to Duo: %s (%s)", *check.StatResult.Message, *check.StatResult.Message_Detail) + } + return duo_auth_client, nil +} + +func pathDuoAccessWrite( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + entry, err := logical.StorageEntryJSON("duo/access", DuoAccess{ + SKey: d.Get("skey").(string), + IKey: d.Get("ikey").(string), + Host: d.Get("host").(string), + }) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +type DuoAccess struct { + SKey string `json:"skey"` + IKey string `json:"ikey"` + Host string `json:"host"` +} + +const pathDuoAccessHelpSyn = ` +Configure the access keys and host for Duo API connections. +` + +const pathDuoAccessHelpDesc = ` +To authenticate users with Duo, the backend needs to know what host to connect to +and must authenticate with an integration key and secret key. This endpoint is used +to configure that information. +` diff --git a/helper/mfa/duo/path_duo_config.go b/helper/mfa/duo/path_duo_config.go new file mode 100644 index 0000000000..33b6589751 --- /dev/null +++ b/helper/mfa/duo/path_duo_config.go @@ -0,0 +1,103 @@ +package duo + +import ( + "fmt" + "strings" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathDuoConfig() *framework.Path { + return &framework.Path{ + Pattern: `duo/config`, + Fields: map[string]*framework.FieldSchema{ + "user_agent": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "User agent to connect to Duo (default \"\")", + }, + "username_format": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Format string given auth backend username as argument to create Duo username (default '%s')", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.WriteOperation: pathDuoConfigWrite, + logical.ReadOperation: pathDuoConfigRead, + }, + + HelpSynopsis: pathDuoConfigHelpSyn, + HelpDescription: pathDuoConfigHelpDesc, + } +} + +func GetDuoConfig(req *logical.Request) (*DuoConfig, error) { + entry, err := req.Storage.Get("duo/config") + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + var result DuoConfig + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + if result.UsernameFormat == "" { + result.UsernameFormat = "%s" + } + return &result, nil +} + +func pathDuoConfigWrite( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + username_format := d.Get("username_format").(string) + if !strings.Contains(username_format, "%s") { + return nil, fmt.Errorf("username_format must include username ('%s')") + } + entry, err := logical.StorageEntryJSON("duo/config", DuoConfig{ + UsernameFormat: username_format, + }) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +func pathDuoConfigRead( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + + config, err := GetDuoConfig(req) + if err != nil { + return nil, err + } + if config == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "username_format": config.UsernameFormat, + }, + }, nil +} + +type DuoConfig struct { + UsernameFormat string `json:"username_format"` + UserAgent string `json:"user_agent"` +} + +const pathDuoConfigHelpSyn = ` +Configure Duo second factor behavior. +` + +const pathDuoConfigHelpDesc = ` +This endpoint allows you to configure how the original auth backend username maps to +the Duo username by providing a template format string. +` diff --git a/helper/mfa/mfa.go b/helper/mfa/mfa.go new file mode 100644 index 0000000000..da18a9b783 --- /dev/null +++ b/helper/mfa/mfa.go @@ -0,0 +1,59 @@ +package mfa + +import ( + "github.com/hashicorp/vault/helper/mfa/duo" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func MFAPaths(originalBackend *framework.Backend, loginPath *framework.Path) []*framework.Path { + var b backend + b.Backend = originalBackend + return append(duo.DuoPaths(), pathMFAConfig(&b), wrapLoginPath(&b, loginPath)) +} + +func MFAPathsSpecial() []string { + return append(duo.DuoPathsSpecial(), "mfa_config") +} + +type backend struct { + *framework.Backend +} + +func wrapLoginPath(b *backend, loginPath *framework.Path) *framework.Path { + (*loginPath).Fields["passcode"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: "One time passcode (optional)", + } + (*loginPath).Fields["method"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Multi-factor auth method to use (optional)", + } + // wrap write callback to do duo two factor after auth + loginHandler := loginPath.Callbacks[logical.WriteOperation] + loginPath.Callbacks[logical.WriteOperation] = b.wrapLoginHandler(loginHandler) + return loginPath +} + +func (b *backend) wrapLoginHandler(loginHandler framework.OperationFunc) framework.OperationFunc { + return func (req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + // login with original login function first + resp, err := loginHandler(req, d); + if err != nil || resp.Auth == nil { + return resp, err + } + + // check if multi-factor enabled + mfa_config, err := b.MFAConfig(req) + if err != nil || mfa_config == nil { + return resp, nil + } + + switch (mfa_config.Type) { + case "duo": + return duo.DuoHandler(req, d, resp) + default: + return resp, err + } + } +} diff --git a/helper/mfa/path_mfa_config.go b/helper/mfa/path_mfa_config.go new file mode 100644 index 0000000000..55a2619480 --- /dev/null +++ b/helper/mfa/path_mfa_config.go @@ -0,0 +1,88 @@ +package mfa + +import ( + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathMFAConfig(b *backend) *framework.Path { + return &framework.Path{ + Pattern: `mfa_config`, + Fields: map[string]*framework.FieldSchema{ + "type": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Enables MFA with given backend (available: duo)", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.WriteOperation: b.pathMFAConfigWrite, + logical.ReadOperation: b.pathMFAConfigRead, + }, + + HelpSynopsis: pathMFAConfigHelpSyn, + HelpDescription: pathMFAConfigHelpDesc, + } +} + +func (b *backend) MFAConfig(req *logical.Request) (*MFAConfig, error) { + entry, err := req.Storage.Get("mfa_config") + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + var result MFAConfig + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +func (b *backend) pathMFAConfigWrite( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + entry, err := logical.StorageEntryJSON("mfa_config", MFAConfig{ + Type: d.Get("type").(string), + }) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +func (b *backend) pathMFAConfigRead( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + + config, err := b.MFAConfig(req) + if err != nil { + return nil, err + } + if config == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "type": config.Type, + }, + }, nil +} + +type MFAConfig struct { + Type string `json:"type"` +} + +const pathMFAConfigHelpSyn = ` +Configure multi factor backend. +` + +const pathMFAConfigHelpDesc = ` +This endpoint allows you to turn on multi-factor authentication with a given backend. +Currently only Duo is supported. +` From 5afc6115c746b552e1ea6cb68b2e3fb14abec9a4 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 11:24:12 -0700 Subject: [PATCH 02/12] ldap: add mfa to LDAP login --- builtin/credential/ldap/backend.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go index 8558818676..c65f80f75d 100644 --- a/builtin/credential/ldap/backend.go +++ b/builtin/credential/ldap/backend.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/go-ldap/ldap" + "github.com/hashicorp/vault/helper/mfa" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" ) @@ -18,11 +19,13 @@ func Backend() *framework.Backend { Help: backendHelp, PathsSpecial: &logical.Paths{ - Root: []string{ + Root: append([]string{ "config", "groups/*", "users/*", }, + mfa.MFAPathsSpecial()..., + ), Unauthenticated: []string{ "login/*", @@ -30,11 +33,12 @@ func Backend() *framework.Backend { }, Paths: append([]*framework.Path{ - pathLogin(&b), pathConfig(&b), pathGroups(&b), pathUsers(&b), - }), + }, + mfa.MFAPaths(b.Backend, pathLogin(&b))..., + ), AuthRenew: b.pathLoginRenew, } From 85a4d740b5ee86be3748a937ef3719383b31ac1b Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 11:28:09 -0700 Subject: [PATCH 03/12] ldap: add mfa support to CLI --- builtin/credential/ldap/cli.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/builtin/credential/ldap/cli.go b/builtin/credential/ldap/cli.go index 38d6c2ef42..b8a2c1cf67 100644 --- a/builtin/credential/ldap/cli.go +++ b/builtin/credential/ldap/cli.go @@ -32,10 +32,21 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (string, error) { } } - path := fmt.Sprintf("auth/%s/login/%s", mount, username) - secret, err := c.Logical().Write(path, map[string]interface{}{ + data := map[string]interface{}{ "password": password, - }) + } + + mfa_method, ok := m["method"] + if ok { + data["method"] = mfa_method + } + mfa_passcode, ok := m["passcode"] + if ok { + data["passcode"] = mfa_passcode + } + + path := fmt.Sprintf("auth/%s/login/%s", mount, username) + secret, err := c.Logical().Write(path, data) if err != nil { return "", err } From 4b87af123d8351023fd66982e53f1c01083ea484 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 18:05:06 -0700 Subject: [PATCH 04/12] mfa: add test cases for MFA, Duo --- helper/mfa/duo/duo.go | 49 +++++++----- helper/mfa/duo/duo_test.go | 114 +++++++++++++++++++++++++++ helper/mfa/duo/path_duo_access.go | 16 ++-- helper/mfa/mfa.go | 18 +++-- helper/mfa/mfa_test.go | 123 ++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 32 deletions(-) create mode 100644 helper/mfa/duo/duo_test.go create mode 100644 helper/mfa/mfa_test.go diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go index 7f2824eab9..6d55691848 100644 --- a/helper/mfa/duo/duo.go +++ b/helper/mfa/duo/duo.go @@ -25,12 +25,12 @@ func DuoPathsSpecial() []string { func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Response) ( *logical.Response, error) { - duo_config, err := GetDuoConfig(req) - if err != nil || duo_config == nil { + duoConfig, err := GetDuoConfig(req) + if err != nil || duoConfig == nil { return logical.ErrorResponse("Could not load Duo configuration"), nil } - duo_auth_client, err := GetDuoAuthClient(req, duo_config) + duoAuthClient, err := GetDuoAuthClient(req, duoConfig) if err != nil { return logical.ErrorResponse(err.Error()), nil } @@ -40,23 +40,34 @@ func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Resp return logical.ErrorResponse("Could not read username for MFA"), nil } - duo_user := fmt.Sprintf(duo_config.UsernameFormat, username) + method := d.Get("method").(string) + passcode := d.Get("passcode").(string) - preauth, err := duo_auth_client.Preauth( - authapi.PreauthUsername(duo_user), - authapi.PreauthIpAddr(req.Connection.RemoteAddr), + return duoHandler(duoConfig, duoAuthClient, resp, + username, method, passcode, req.Connection.RemoteAddr) +} + +func duoHandler( + duoConfig *DuoConfig, duoAuthClient AuthClient, successResp *logical.Response, + username string, method string, passcode string, ipAddr string) (*logical.Response, error) { + + duoUser := fmt.Sprintf(duoConfig.UsernameFormat, username) + + preauth, err := duoAuthClient.Preauth( + authapi.PreauthUsername(duoUser), + authapi.PreauthIpAddr(ipAddr), ) if preauth.StatResult.Stat != "OK" { - return logical.ErrorResponse(fmt.Sprintf("Could not look up Duo user information: %s (%s)", - *preauth.StatResult.Message, - *preauth.StatResult.Message_Detail, + return logical.ErrorResponse(fmt.Sprintf("Could not look up Duo user information: %v (%v)", + preauth.StatResult.Message, + preauth.StatResult.Message_Detail, )), nil } switch preauth.Response.Result { case "allow": - return resp, err + return successResp, err case "deny": return logical.ErrorResponse(preauth.Response.Status_Msg), nil case "enroll": @@ -65,14 +76,10 @@ func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Resp break } - options := []func(*url.Values){authapi.AuthUsername(duo_user)} - - method := d.Get("method").(string) + options := []func(*url.Values){authapi.AuthUsername(duoUser)} if method == "" { method = "auto" } - - passcode := d.Get("passcode").(string) if passcode != "" { method = "passcode" options = append(options, authapi.AuthPasscode(passcode)) @@ -80,16 +87,16 @@ func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Resp options = append(options, authapi.AuthDevice("auto")) } - result, err := duo_auth_client.Auth(method, options...) + result, err := duoAuthClient.Auth(method, options...) if err != nil { return logical.ErrorResponse("Could not call Duo auth"), nil } if result.StatResult.Stat != "OK" { - return logical.ErrorResponse(fmt.Sprintf("Could not authenticate Duo user: %s (%s)", - *preauth.StatResult.Message, - *preauth.StatResult.Message_Detail, + return logical.ErrorResponse(fmt.Sprintf("Could not authenticate Duo user: %v (%v)", + preauth.StatResult.Message, + preauth.StatResult.Message_Detail, )), nil } @@ -97,5 +104,5 @@ func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Resp return logical.ErrorResponse(result.Response.Status_Msg), nil } - return resp, err + return successResp, err } diff --git a/helper/mfa/duo/duo_test.go b/helper/mfa/duo/duo_test.go new file mode 100644 index 0000000000..64e9dcd976 --- /dev/null +++ b/helper/mfa/duo/duo_test.go @@ -0,0 +1,114 @@ +package duo + +import ( + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/duosecurity/duo_api_golang/authapi" + "github.com/hashicorp/vault/logical" +) + +type MockClientData struct { + PreauthData *authapi.PreauthResult + PreauthError error + AuthData *authapi.AuthResult + AuthError error +} + +type MockAuthClient struct { + MockData *MockClientData +} + +func (c *MockAuthClient) Preauth(options ...func(*url.Values)) (*authapi.PreauthResult, error) { + return c.MockData.PreauthData, c.MockData.PreauthError +} + +func (c *MockAuthClient) Auth(factor string, options ...func(*url.Values)) (*authapi.AuthResult, error) { + return c.MockData.AuthData, c.MockData.AuthError +} + +func MockGetDuoAuthClient(data *MockClientData) func (*logical.Request, *DuoConfig) (AuthClient, error) { + return func (*logical.Request, *DuoConfig) (AuthClient, error) { + return getDuoAuthClient(data), nil + } +} + +func getDuoAuthClient(data *MockClientData) AuthClient { + var c MockAuthClient + // set default response to auth user + if data.PreauthData == nil { + data.PreauthData = &authapi.PreauthResult{} + json.Unmarshal([]byte(` +{ + "Stat": "OK", + "Response": { + "Result": "auth", + "Status_Msg": "Needs authentication", + "Devices": [] + } +}`), data.PreauthData) + } + + if data.AuthData == nil { + data.AuthData = &authapi.AuthResult{} + json.Unmarshal([]byte(` +{ + "Stat": "OK", + "Response": { + "Result": "allow" + } +}`), data.AuthData) + } + + c.MockData = data + return &c +} + +func TestDuoHandlerSuccess(t *testing.T) { + successResp := &logical.Response{ + Auth: &logical.Auth{}, + } + duoConfig := &DuoConfig{ + UsernameFormat: "%s", + } + duoAuthClient := getDuoAuthClient(&MockClientData{}) + resp, err := duoHandler(duoConfig, duoAuthClient, successResp, "user", "", "", "") + if err != nil { + t.Fatalf(err.Error()) + } + if resp != successResp { + t.Fatalf("Testing Duo authentication gave incorrect response (expected success, got: %v)", resp) + } +} + +func TestDuoHandlerReject(t *testing.T) { + AuthData := &authapi.AuthResult{} + json.Unmarshal([]byte(` +{ + "Stat": "OK", + "Response": { + "Result": "deny", + "Status_Msg": "Invalid auth" + } +}`), AuthData) + successResp := &logical.Response{ + Auth: &logical.Auth{}, + } + expectedError := AuthData.Response.Status_Msg + duoConfig := &DuoConfig{ + UsernameFormat: "%s", + } + duoAuthClient := getDuoAuthClient(&MockClientData{ + AuthData: AuthData, + }) + resp, err := duoHandler(duoConfig, duoAuthClient, successResp, "user", "", "", "") + if err != nil { + t.Fatalf(err.Error()) + } + error, ok := resp.Data["error"].(string) + if !ok || !strings.Contains(error, expectedError) { + t.Fatalf("Testing Duo authentication gave incorrect response (expected deny, got: %v)", error) + } +} diff --git a/helper/mfa/duo/path_duo_access.go b/helper/mfa/duo/path_duo_access.go index 271ea996cc..3b43233944 100644 --- a/helper/mfa/duo/path_duo_access.go +++ b/helper/mfa/duo/path_duo_access.go @@ -2,6 +2,7 @@ package duo import ( "fmt" + "net/url" "github.com/duosecurity/duo_api_golang" "github.com/duosecurity/duo_api_golang/authapi" @@ -9,6 +10,11 @@ import ( "github.com/hashicorp/vault/logical/framework" ) +type AuthClient interface { + Preauth(options ...func(*url.Values)) (*authapi.PreauthResult, error) + Auth(factor string, options ...func(*url.Values)) (*authapi.AuthResult, error) +} + func pathDuoAccess() *framework.Path { return &framework.Path{ Pattern: `duo/access`, @@ -36,7 +42,7 @@ func pathDuoAccess() *framework.Path { } } -func GetDuoAuthClient(req *logical.Request, config *DuoConfig) (*authapi.AuthApi, error) { +func GetDuoAuthClient(req *logical.Request, config *DuoConfig) (AuthClient, error) { entry, err := req.Storage.Get("duo/access") if err != nil { return nil, err @@ -51,21 +57,21 @@ func GetDuoAuthClient(req *logical.Request, config *DuoConfig) (*authapi.AuthApi return nil, err } - duo_client := duoapi.NewDuoApi( + duoClient := duoapi.NewDuoApi( access.IKey, access.SKey, access.Host, config.UserAgent, ) - duo_auth_client := authapi.NewAuthApi(*duo_client) - check, err := duo_auth_client.Check() + duoAuthClient := authapi.NewAuthApi(*duoClient) + check, err := duoAuthClient.Check() if err != nil { return nil, err } if check.StatResult.Stat != "OK" { return nil, fmt.Errorf("Could not connect to Duo: %s (%s)", *check.StatResult.Message, *check.StatResult.Message_Detail) } - return duo_auth_client, nil + return duoAuthClient, nil } func pathDuoAccessWrite( diff --git a/helper/mfa/mfa.go b/helper/mfa/mfa.go index da18a9b783..1e87c8b26b 100644 --- a/helper/mfa/mfa.go +++ b/helper/mfa/mfa.go @@ -16,16 +16,22 @@ func MFAPathsSpecial() []string { return append(duo.DuoPathsSpecial(), "mfa_config") } +type HandlerFunc func (*logical.Request, *framework.FieldData, *logical.Response) (*logical.Response, error) + +var handlers = map[string]HandlerFunc{ + "duo": duo.DuoHandler, +} + type backend struct { *framework.Backend } func wrapLoginPath(b *backend, loginPath *framework.Path) *framework.Path { - (*loginPath).Fields["passcode"] = &framework.FieldSchema{ + loginPath.Fields["passcode"] = &framework.FieldSchema{ Type: framework.TypeString, Description: "One time passcode (optional)", } - (*loginPath).Fields["method"] = &framework.FieldSchema{ + loginPath.Fields["method"] = &framework.FieldSchema{ Type: framework.TypeString, Description: "Multi-factor auth method to use (optional)", } @@ -49,10 +55,10 @@ func (b *backend) wrapLoginHandler(loginHandler framework.OperationFunc) framewo return resp, nil } - switch (mfa_config.Type) { - case "duo": - return duo.DuoHandler(req, d, resp) - default: + handler, ok := handlers[mfa_config.Type] + if ok { + return handler(req, d, resp) + } else { return resp, err } } diff --git a/helper/mfa/mfa_test.go b/helper/mfa/mfa_test.go new file mode 100644 index 0000000000..c85e6a5b6d --- /dev/null +++ b/helper/mfa/mfa_test.go @@ -0,0 +1,123 @@ +package mfa + +import ( + "testing" + + "github.com/hashicorp/vault/logical" + logicaltest "github.com/hashicorp/vault/logical/testing" + "github.com/hashicorp/vault/logical/framework" +) + +func MakeTestBackend() *framework.Backend { + handlers["test"] = testMFAHandler + b := &framework.Backend{ + Help: "", + + PathsSpecial: &logical.Paths{ + Root: MFAPathsSpecial(), + Unauthenticated: []string{ + "login", + }, + }, + Paths: MFAPaths(nil, testPathLogin()), + } + return b +} + +func testPathLogin() *framework.Path { + return &framework.Path{ + Pattern: `login`, + Fields: map[string]*framework.FieldSchema{ + "username": &framework.FieldSchema{ + Type: framework.TypeString, + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.WriteOperation: testPathLoginHandler, + }, + } +} + +func testPathLoginHandler( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + username := d.Get("username").(string) + + return &logical.Response{ + Auth: &logical.Auth{ + Policies: []string{"foo"}, + Metadata: map[string]string{ + "username": username, + }, + }, + }, nil +} + +func testMFAHandler(req *logical.Request, d *framework.FieldData, resp *logical.Response) ( + *logical.Response, error) { + if d.Get("method").(string) != "accept" { + return logical.ErrorResponse("Deny access"), nil + } else { + return resp, nil + } +} + +func TestMFALogin(t *testing.T) { + b := MakeTestBackend() + + logicaltest.Test(t, logicaltest.TestCase{ + Backend: b, + Steps: []logicaltest.TestStep{ + testAccStepEnableMFA(t), + testAccStepLogin(t, "user"), + }, + }) +} + +func TestMFALoginDenied(t *testing.T) { + b := MakeTestBackend() + + logicaltest.Test(t, logicaltest.TestCase{ + Backend: b, + Steps: []logicaltest.TestStep{ + testAccStepEnableMFA(t), + testAccStepLoginDenied(t, "user"), + }, + }) +} + +func testAccStepEnableMFA(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "mfa_config", + Data: map[string]interface{}{ + "type": "test", + }, + } +} + +func testAccStepLogin(t *testing.T, username string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "login", + Data: map[string]interface{}{ + "method": "accept", + "username": username, + }, + Unauthenticated: true, + Check: logicaltest.TestCheckAuth([]string{"foo"}), + } +} + +func testAccStepLoginDenied(t *testing.T, username string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "login", + Data: map[string]interface{}{ + "method": "deny", + "username": username, + }, + Unauthenticated: true, + Check: logicaltest.TestCheckError(), + } +} From 0efdcb7ae0c31f3beba5ea610da5d7de34ad66ed Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 20:38:49 -0700 Subject: [PATCH 05/12] mfa duo: better error messages --- helper/mfa/duo/duo.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go index 6d55691848..f4fa856235 100644 --- a/helper/mfa/duo/duo.go +++ b/helper/mfa/duo/duo.go @@ -59,10 +59,14 @@ func duoHandler( ) if preauth.StatResult.Stat != "OK" { - return logical.ErrorResponse(fmt.Sprintf("Could not look up Duo user information: %v (%v)", - preauth.StatResult.Message, - preauth.StatResult.Message_Detail, - )), nil + errorMsg := "Could not look up Duo user information" + if preauth.StatResult.Message != nil { + errorMsg = errorMsg + ": " + *preauth.StatResult.Message + } + if preauth.StatResult.Message_Detail != nil { + errorMsg = errorMsg + " (" + *preauth.StatResult.Message_Detail + ")" + } + return logical.ErrorResponse(errorMsg), nil } switch preauth.Response.Result { @@ -94,10 +98,14 @@ func duoHandler( } if result.StatResult.Stat != "OK" { - return logical.ErrorResponse(fmt.Sprintf("Could not authenticate Duo user: %v (%v)", - preauth.StatResult.Message, - preauth.StatResult.Message_Detail, - )), nil + errorMsg := "Could not authenticate Duo user" + if result.StatResult.Message != nil { + errorMsg = errorMsg + ": " + *result.StatResult.Message + } + if result.StatResult.Message_Detail != nil { + errorMsg = errorMsg + " (" + *result.StatResult.Message_Detail + ")" + } + return logical.ErrorResponse(errorMsg), nil } if result.Response.Result != "allow" { From d47a12f024bc1741dcd282322ffd62b6ffd24148 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 21:11:35 -0700 Subject: [PATCH 06/12] mfa: add to userpass backend --- builtin/credential/userpass/backend.go | 10 +++++-- builtin/credential/userpass/cli.go | 38 ++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/builtin/credential/userpass/backend.go b/builtin/credential/userpass/backend.go index 0697361ea8..a3e35211cd 100644 --- a/builtin/credential/userpass/backend.go +++ b/builtin/credential/userpass/backend.go @@ -1,6 +1,7 @@ package userpass import ( + "github.com/hashicorp/vault/helper/mfa" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" ) @@ -15,9 +16,11 @@ func Backend() *framework.Backend { Help: backendHelp, PathsSpecial: &logical.Paths{ - Root: []string{ + Root: append([]string{ "users/*", }, + mfa.MFAPathsSpecial()..., + ), Unauthenticated: []string{ "login/*", @@ -25,9 +28,10 @@ func Backend() *framework.Backend { }, Paths: append([]*framework.Path{ - pathLogin(&b), pathUsers(&b), - }), + }, + mfa.MFAPaths(b.Backend, pathLogin(&b))..., + ), AuthRenew: b.pathLoginRenew, } diff --git a/builtin/credential/userpass/cli.go b/builtin/credential/userpass/cli.go index 08cee9e57f..0ceafc4a95 100644 --- a/builtin/credential/userpass/cli.go +++ b/builtin/credential/userpass/cli.go @@ -3,9 +3,11 @@ package userpass import ( "fmt" "strings" + "os" "github.com/hashicorp/vault/api" "github.com/mitchellh/mapstructure" + pwd "github.com/hashicorp/vault/helper/password" ) type CLIHandler struct{} @@ -15,22 +17,41 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (string, error) { Username string `mapstructure:"username"` Password string `mapstructure:"password"` Mount string `mapstructure:"mount"` + Method string `mapstructure:"method"` + Passcode string `mapstructure:"passcode"` } if err := mapstructure.WeakDecode(m, &data); err != nil { return "", err } - if data.Username == "" || data.Password == "" { - return "", fmt.Errorf("Both 'username' and 'password' must be specified") + if data.Username == "" { + return "", fmt.Errorf("'username' must be specified") + } + if data.Password == "" { + fmt.Printf("Password (will be hidden): ") + var err error + data.Password, err = pwd.Read(os.Stdin) + fmt.Println() + if err != nil { + return "", err + } } if data.Mount == "" { data.Mount = "userpass" } - path := fmt.Sprintf("auth/%s/login/%s", data.Mount, data.Username) - secret, err := c.Logical().Write(path, map[string]interface{}{ + options := map[string]interface{}{ "password": data.Password, - }) + } + if data.Method != "" { + options["method"] = data.Method + } + if data.Passcode != "" { + options["passcode"] = data.Passcode + } + + path := fmt.Sprintf("auth/%s/login/%s", data.Mount, data.Username) + secret, err := c.Logical().Write(path, options) if err != nil { return "", err } @@ -45,7 +66,12 @@ func (h *CLIHandler) Help() string { help := ` The "userpass" credential provider allows you to authenticate with a username and password. To use it, specify the "username" and "password" -parameters. +parameters. If password is not provided on the command line, it will be +read from stdin. + +If multi-factor authentication (MFA) is enabled, a "method" and/or "passcode" +may be provided depending on the MFA backend enabled. To check +which MFA backend is in use, read "auth/[mount]/mfa_config". Example: vault auth -method=userpass \ username= \ From 083226f317da020e559b3efddd1be3e636bd12ce Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Mon, 27 Jul 2015 21:12:11 -0700 Subject: [PATCH 07/12] mfa: improve edge cases and documentation --- builtin/credential/ldap/cli.go | 4 ++++ helper/mfa/duo/duo.go | 4 +++- helper/mfa/duo/path_duo_config.go | 15 ++++++--------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/builtin/credential/ldap/cli.go b/builtin/credential/ldap/cli.go index b8a2c1cf67..79200645c9 100644 --- a/builtin/credential/ldap/cli.go +++ b/builtin/credential/ldap/cli.go @@ -64,6 +64,10 @@ To use it, first configure it through the "config" endpoint, and then login by specifying username and password. If password is not provided on the command line, it will be read from stdin. +If multi-factor authentication (MFA) is enabled, a "method" and/or "passcode" +may be provided depending on the MFA backend enabled. To check +which MFA backend is in use, read "auth/[mount]/mfa_config". + Example: vault auth -method=ldap username=john ` diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go index f4fa856235..3f0dfd7c30 100644 --- a/helper/mfa/duo/duo.go +++ b/helper/mfa/duo/duo.go @@ -75,7 +75,9 @@ func duoHandler( case "deny": return logical.ErrorResponse(preauth.Response.Status_Msg), nil case "enroll": - return logical.ErrorResponse(preauth.Response.Status_Msg), nil + return logical.ErrorResponse(fmt.Sprintf("%s (%s)", + preauth.Response.Status_Msg, + preauth.Response.Enroll_Portal_Url)), nil case "auth": break } diff --git a/helper/mfa/duo/path_duo_config.go b/helper/mfa/duo/path_duo_config.go index 33b6589751..9a16a6ef28 100644 --- a/helper/mfa/duo/path_duo_config.go +++ b/helper/mfa/duo/path_duo_config.go @@ -33,16 +33,13 @@ func pathDuoConfig() *framework.Path { } func GetDuoConfig(req *logical.Request) (*DuoConfig, error) { - entry, err := req.Storage.Get("duo/config") - if err != nil { - return nil, err - } - if entry == nil { - return nil, nil - } var result DuoConfig - if err := entry.DecodeJSON(&result); err != nil { - return nil, err + // all config parameters are optional, so path need not exist + entry, err := req.Storage.Get("duo/config") + if err == nil && entry != nil { + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } } if result.UsernameFormat == "" { result.UsernameFormat = "%s" From 4a862163ac960b6963211ecc221a2dbe0255edf4 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Tue, 28 Jul 2015 11:00:57 -0700 Subject: [PATCH 08/12] mfa: add website documentation --- website/source/docs/auth/ldap.html.md | 2 +- website/source/docs/auth/mfa.html.md | 75 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 website/source/docs/auth/mfa.html.md diff --git a/website/source/docs/auth/ldap.html.md b/website/source/docs/auth/ldap.html.md index f36307c04c..cf4c2fb5be 100644 --- a/website/source/docs/auth/ldap.html.md +++ b/website/source/docs/auth/ldap.html.md @@ -3,7 +3,7 @@ layout: "docs" page_title: "Auth Backend: LDAP" sidebar_current: "docs-auth-ldap" description: |- - The "kdap" auth backend allows users to authenticate with Vault using LDAP credentials. + The "ldap" auth backend allows users to authenticate with Vault using LDAP credentials. --- # Auth Backend: LDAP diff --git a/website/source/docs/auth/mfa.html.md b/website/source/docs/auth/mfa.html.md new file mode 100644 index 0000000000..d2087ac636 --- /dev/null +++ b/website/source/docs/auth/mfa.html.md @@ -0,0 +1,75 @@ +--- +layout: "docs" +page_title: "Multi-Factor Authentication" +sidebar_current: "docs-auth-mfa" +description: |- + Multi-factor authentication is supported for several authentication backends. +--- + +# Multi-Factor Authentication + +Several authentication backends support multi-factor authentication (MFA). Once enabled for +a backend, users are required to provide additional verification, like a one-time passcode, +before being authenticated. + +Currently, the "ldap" and "userpass" backends support MFA. + +## Authentication + +When authenticating, users still provide the same information as before, as well as +MFA verification. Usually this is a passcode, but in other cases, like a Duo Push +notification, no additional information is needed. + +### Via the CLI + +```shell +$ vault auth -method=userpass username=user password=test passcode=111111 +$ vault auth -method=userpass username=user password=test method=push # (default) +``` + +### Via the API + +The endpoint for the login is the same as for the original backend. Additional +MFA information should be sent in the POST body encoded as JSON. + +```shell +$ curl $VAULT_ADDR/v1/auth/userpass/login/user \ + -d '{ "password": "test", "passcode": "111111" }' +``` + +The response is the same as for the original backend. + +## Configuration + +To enable MFA for a supported backend, the MFA type must be set in `mfa_config`. For example: + +```shell +$ vault write auth/userpass/mfa_config type=duo +``` + +This enables the Duo MFA type, which is currently the only MFA type supported. + +### Duo + +The Duo MFA type is configured through two paths: `duo/config` and `duo/access`. + +`duo/access` contains connection information for the Duo Auth API. For example: + +```shell +$ vault write auth/userpass/duo/access \ + host=[host] \ + ikey=[integration key] \ + skey=[secret key] +``` + +`duo/config` is an optional path that contains general configuration information +for Duo authentication. For example: + +```shell +$ vault write auth/userpass/duo/config \ + user_agent="" \ + username_format="%s" +``` + +`username_format` is a format string that is formatted with the original backend's +username as the first argument to produce the Duo username. From c7b806ebf6104876f334fedfe61d503bdf1b9f4a Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Tue, 28 Jul 2015 11:55:46 -0700 Subject: [PATCH 09/12] mfa: code cleanup --- builtin/credential/userpass/cli.go | 4 +- helper/mfa/duo/duo.go | 53 ++++++++++++++++--------- helper/mfa/duo/duo_test.go | 63 +++++++++++++++++------------- helper/mfa/duo/path_duo_config.go | 4 +- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/builtin/credential/userpass/cli.go b/builtin/credential/userpass/cli.go index 0ceafc4a95..c5d5cc27af 100644 --- a/builtin/credential/userpass/cli.go +++ b/builtin/credential/userpass/cli.go @@ -29,12 +29,12 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (string, error) { } if data.Password == "" { fmt.Printf("Password (will be hidden): ") - var err error - data.Password, err = pwd.Read(os.Stdin) + password, err := pwd.Read(os.Stdin) fmt.Println() if err != nil { return "", err } + data.Password = password } if data.Mount == "" { data.Mount = "userpass" diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go index 3f0dfd7c30..3313928a19 100644 --- a/helper/mfa/duo/duo.go +++ b/helper/mfa/duo/duo.go @@ -40,24 +40,38 @@ func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Resp return logical.ErrorResponse("Could not read username for MFA"), nil } - method := d.Get("method").(string) - passcode := d.Get("passcode").(string) + var request *duoAuthRequest = &duoAuthRequest{} + request.successResp = resp + request.username = username + request.method = d.Get("method").(string) + request.passcode = d.Get("passcode").(string) + request.ipAddr = req.Connection.RemoteAddr - return duoHandler(duoConfig, duoAuthClient, resp, - username, method, passcode, req.Connection.RemoteAddr) + return duoHandler(duoConfig, duoAuthClient, request) } -func duoHandler( - duoConfig *DuoConfig, duoAuthClient AuthClient, successResp *logical.Response, - username string, method string, passcode string, ipAddr string) (*logical.Response, error) { +type duoAuthRequest struct { + successResp *logical.Response + username string + method string + passcode string + ipAddr string +} - duoUser := fmt.Sprintf(duoConfig.UsernameFormat, username) +func duoHandler(duoConfig *DuoConfig, duoAuthClient AuthClient, request *duoAuthRequest) ( + *logical.Response, error) { + + duoUser := fmt.Sprintf(duoConfig.UsernameFormat, request.username) preauth, err := duoAuthClient.Preauth( authapi.PreauthUsername(duoUser), - authapi.PreauthIpAddr(ipAddr), + authapi.PreauthIpAddr(request.ipAddr), ) + if err != nil || preauth == nil { + return logical.ErrorResponse("Could not call Duo preauth"), nil + } + if preauth.StatResult.Stat != "OK" { errorMsg := "Could not look up Duo user information" if preauth.StatResult.Message != nil { @@ -71,7 +85,7 @@ func duoHandler( switch preauth.Response.Result { case "allow": - return successResp, err + return request.successResp, err case "deny": return logical.ErrorResponse(preauth.Response.Status_Msg), nil case "enroll": @@ -80,22 +94,25 @@ func duoHandler( preauth.Response.Enroll_Portal_Url)), nil case "auth": break + default: + return logical.ErrorResponse(fmt.Sprintf("Invalid Duo preauth response: %s", + preauth.Response.Result)), nil } options := []func(*url.Values){authapi.AuthUsername(duoUser)} - if method == "" { - method = "auto" + if request.method == "" { + request.method = "auto" } - if passcode != "" { - method = "passcode" - options = append(options, authapi.AuthPasscode(passcode)) + if request.passcode != "" { + request.method = "passcode" + options = append(options, authapi.AuthPasscode(request.passcode)) } else { options = append(options, authapi.AuthDevice("auto")) } - result, err := duoAuthClient.Auth(method, options...) + result, err := duoAuthClient.Auth(request.method, options...) - if err != nil { + if err != nil || result == nil { return logical.ErrorResponse("Could not call Duo auth"), nil } @@ -114,5 +131,5 @@ func duoHandler( return logical.ErrorResponse(result.Response.Status_Msg), nil } - return successResp, err + return request.successResp, nil } diff --git a/helper/mfa/duo/duo_test.go b/helper/mfa/duo/duo_test.go index 64e9dcd976..e01272d353 100644 --- a/helper/mfa/duo/duo_test.go +++ b/helper/mfa/duo/duo_test.go @@ -37,29 +37,31 @@ func MockGetDuoAuthClient(data *MockClientData) func (*logical.Request, *DuoConf func getDuoAuthClient(data *MockClientData) AuthClient { var c MockAuthClient - // set default response to auth user + // set default response to be successful + preauthSuccessJSON := ` + { + "Stat": "OK", + "Response": { + "Result": "auth", + "Status_Msg": "Needs authentication", + "Devices": [] + } + }` if data.PreauthData == nil { data.PreauthData = &authapi.PreauthResult{} - json.Unmarshal([]byte(` -{ - "Stat": "OK", - "Response": { - "Result": "auth", - "Status_Msg": "Needs authentication", - "Devices": [] - } -}`), data.PreauthData) + json.Unmarshal([]byte(preauthSuccessJSON), data.PreauthData) } + authSuccessJSON := ` + { + "Stat": "OK", + "Response": { + "Result": "allow" + } + }` if data.AuthData == nil { data.AuthData = &authapi.AuthResult{} - json.Unmarshal([]byte(` -{ - "Stat": "OK", - "Response": { - "Result": "allow" - } -}`), data.AuthData) + json.Unmarshal([]byte(authSuccessJSON), data.AuthData) } c.MockData = data @@ -74,7 +76,10 @@ func TestDuoHandlerSuccess(t *testing.T) { UsernameFormat: "%s", } duoAuthClient := getDuoAuthClient(&MockClientData{}) - resp, err := duoHandler(duoConfig, duoAuthClient, successResp, "user", "", "", "") + resp, err := duoHandler(duoConfig, duoAuthClient, &duoAuthRequest { + successResp: successResp, + username: "", + }) if err != nil { t.Fatalf(err.Error()) } @@ -85,14 +90,15 @@ func TestDuoHandlerSuccess(t *testing.T) { func TestDuoHandlerReject(t *testing.T) { AuthData := &authapi.AuthResult{} - json.Unmarshal([]byte(` -{ - "Stat": "OK", - "Response": { - "Result": "deny", - "Status_Msg": "Invalid auth" - } -}`), AuthData) + authRejectJSON := ` + { + "Stat": "OK", + "Response": { + "Result": "deny", + "Status_Msg": "Invalid auth" + } + }` + json.Unmarshal([]byte(authRejectJSON), AuthData) successResp := &logical.Response{ Auth: &logical.Auth{}, } @@ -103,7 +109,10 @@ func TestDuoHandlerReject(t *testing.T) { duoAuthClient := getDuoAuthClient(&MockClientData{ AuthData: AuthData, }) - resp, err := duoHandler(duoConfig, duoAuthClient, successResp, "user", "", "", "") + resp, err := duoHandler(duoConfig, duoAuthClient, &duoAuthRequest { + successResp: successResp, + username: "user", + }) if err != nil { t.Fatalf(err.Error()) } diff --git a/helper/mfa/duo/path_duo_config.go b/helper/mfa/duo/path_duo_config.go index 9a16a6ef28..406072dec1 100644 --- a/helper/mfa/duo/path_duo_config.go +++ b/helper/mfa/duo/path_duo_config.go @@ -1,7 +1,7 @@ package duo import ( - "fmt" + "errors" "strings" "github.com/hashicorp/vault/logical" @@ -51,7 +51,7 @@ func pathDuoConfigWrite( req *logical.Request, d *framework.FieldData) (*logical.Response, error) { username_format := d.Get("username_format").(string) if !strings.Contains(username_format, "%s") { - return nil, fmt.Errorf("username_format must include username ('%s')") + return nil, errors.New("username_format must include username ('%s')") } entry, err := logical.StorageEntryJSON("duo/config", DuoConfig{ UsernameFormat: username_format, From cf4fa83598e33648f8d6897e739860aae9d8ca4c Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Tue, 28 Jul 2015 12:21:43 -0700 Subject: [PATCH 10/12] mfa: cleanup website documentation --- website/source/docs/auth/mfa.html.md | 25 ++++++++++++++++--------- website/source/layouts/docs.erb | 4 ++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/website/source/docs/auth/mfa.html.md b/website/source/docs/auth/mfa.html.md index d2087ac636..382af66de9 100644 --- a/website/source/docs/auth/mfa.html.md +++ b/website/source/docs/auth/mfa.html.md @@ -16,15 +16,23 @@ Currently, the "ldap" and "userpass" backends support MFA. ## Authentication -When authenticating, users still provide the same information as before, as well as +When authenticating, users still provide the same information as before, in addition to MFA verification. Usually this is a passcode, but in other cases, like a Duo Push notification, no additional information is needed. ### Via the CLI ```shell -$ vault auth -method=userpass username=user password=test passcode=111111 -$ vault auth -method=userpass username=user password=test method=push # (default) +$ vault auth -method=userpass \ + username=user \ + password=test \ + passcode=111111 +``` +```shell +$ vault auth -method=userpass \ + username=user \ + password=test \ + method=push ``` ### Via the API @@ -53,23 +61,22 @@ This enables the Duo MFA type, which is currently the only MFA type supported. The Duo MFA type is configured through two paths: `duo/config` and `duo/access`. -`duo/access` contains connection information for the Duo Auth API. For example: +`duo/access` contains connection information for the Duo Auth API. To configure: ```shell -$ vault write auth/userpass/duo/access \ +$ vault write auth/[mount]/duo/access \ host=[host] \ ikey=[integration key] \ skey=[secret key] ``` `duo/config` is an optional path that contains general configuration information -for Duo authentication. For example: +for Duo authentication. To configure: ```shell -$ vault write auth/userpass/duo/config \ +$ vault write auth/[mount]/duo/config \ user_agent="" \ username_format="%s" ``` -`username_format` is a format string that is formatted with the original backend's -username as the first argument to produce the Duo username. +More information can be found through the CLI `path-help` command. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index a1236cd4f7..d7b95e8018 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -162,6 +162,10 @@ > LDAP + + > + MFA + From 4b69073dda4a8469b53eb395cbba1c327f8c09a2 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Tue, 28 Jul 2015 14:08:17 -0700 Subject: [PATCH 11/12] mfa: add duo_api_golang dependency to Godeps --- Godeps/Godeps.json | 4 + .../duosecurity/duo_api_golang/LICENSE | 25 + .../duosecurity/duo_api_golang/README.md | 14 + .../duo_api_golang/authapi/authapi.go | 380 +++++++++++++ .../duo_api_golang/authapi/authapi_test.go | 497 ++++++++++++++++++ .../duosecurity/duo_api_golang/duo_test.go | 156 ++++++ .../duosecurity/duo_api_golang/duoapi.go | 355 +++++++++++++ 7 files changed, 1431 insertions(+) create mode 100644 Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE create mode 100644 Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md create mode 100644 Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go create mode 100644 Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go create mode 100644 Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go create mode 100644 Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index cc3ff58f67..a12e4e8128 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -58,6 +58,10 @@ "Comment": "v2.0.0-18-gc904d70", "Rev": "c904d7032a70da6551c43929f199244f6a45f4c1" }, + { + "ImportPath": "github.com/duosecurity/duo_api_golang", + "Rev": "16da9e74793f6d9b97b227a0696fe32bcdaecb42" + }, { "ImportPath": "github.com/fatih/structs", "Rev": "a9f7daa9c2729e97450c2da2feda19130a367d8f" diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE new file mode 100644 index 0000000000..2510e98ad7 --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2015, Duo Security, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md new file mode 100644 index 0000000000..9216f8cd7d --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/README.md @@ -0,0 +1,14 @@ +# Overview + +**duo_client** - Demonstration client to call Duo API methods +with Go. + +# Duo Auth API + +The Duo Auth API provides a low-level API for adding strong two-factor +authentication to applications that cannot directly display rich web +content. + +For more information see the Duo Auth API guide: + + diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go new file mode 100644 index 0000000000..f8d45036f8 --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi.go @@ -0,0 +1,380 @@ +package authapi + +import( + "github.com/duosecurity/duo_api_golang" + "encoding/json" + "net/url" + "strconv" +) + +type AuthApi struct { + api duoapi.DuoApi +} + +// Build a new Duo Auth API object. +// api is a duoapi.DuoApi object used to make the Duo Rest API calls. +// Example: authapi.NewAuthApi(*duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second))) +func NewAuthApi(api duoapi.DuoApi) *AuthApi { + return &AuthApi{api: api} +} + +// API calls will return a StatResult object. On success, Stat is 'OK'. +// On error, Stat is 'FAIL', and Code, Message, and Message_Detail +// contain error information. +type StatResult struct { + Stat string + Code *int32 + Message *string + Message_Detail *string +} + +// Return object for the 'Ping' API call. +type PingResult struct { + StatResult + Response struct { + Time int64 + } +} + +// Duo's Ping method. https://www.duosecurity.com/docs/authapi#/ping +// This is an unsigned Duo Rest API call which returns the Duo system's time. +// Use this method to determine whether your system time is in sync with Duo's. +func (api *AuthApi) Ping() (*PingResult, error) { + _, body, err := api.api.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + ret := &PingResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +// Return object for the 'Check' API call. +type CheckResult struct { + StatResult + Response struct { + Time int64 + } +} + +// Call Duo's Check method. https://www.duosecurity.com/docs/authapi#/check +// Check is a signed Duo API call, which returns the Duo system's time. +// Use this method to determine whether your ikey, skey and host are correct, +// and whether your system time is in sync with Duo's. +func (api *AuthApi) Check() (*CheckResult, error) { + _, body, err := api.api.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + ret := &CheckResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +// Return object for the 'Logo' API call. +type LogoResult struct { + StatResult + png *[]byte +} + +// Duo's Logo method. https://www.duosecurity.com/docs/authapi#/logo +// If the API call is successful, the configured logo png is returned. Othwerwise, +// error information is returned in the LogoResult return value. +func (api *AuthApi) Logo() (*LogoResult, error) { + resp, body, err := api.api.SignedCall("GET", "/auth/v2/logo", nil, duoapi.UseTimeout) + if err != nil { + return nil, err + } + if resp.StatusCode == 200 { + ret := &LogoResult{StatResult:StatResult{Stat: "OK"}, + png: &body} + return ret, nil + } + ret := &LogoResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +// Optional parameter for the Enroll method. +func EnrollUsername(username string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("username", username) + } +} + +// Optional parameter for the Enroll method. +func EnrollValidSeconds(secs uint64) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("valid_secs", strconv.FormatUint(secs, 10)) + } +} + +// Enroll return type. +type EnrollResult struct { + StatResult + Response struct { + Activation_Barcode string + Activation_Code string + Expiration int64 + User_Id string + Username string + } +} + +// Duo's Enroll method. https://www.duosecurity.com/docs/authapi#/enroll +// Use EnrollUsername() to include the optional username parameter. +// Use EnrollValidSeconds() to change the default validation time limit that the +// user has to complete enrollment. +func (api *AuthApi) Enroll(options ...func(*url.Values)) (*EnrollResult, error) { + opts := url.Values{} + for _, o := range options { + o(&opts) + } + + _, body, err := api.api.SignedCall("POST", "/auth/v2/enroll", opts, duoapi.UseTimeout) + if err != nil { + return nil, err + } + ret := &EnrollResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +// Response is "success", "invalid" or "waiting". +type EnrollStatusResult struct { + StatResult + Response string +} + +// Duo's EnrollStatus method. https://www.duosecurity.com/docs/authapi#/enroll_status +// Return the status of an outstanding Enrollment. +func (api *AuthApi) EnrollStatus(userid string, + activationCode string) (*EnrollStatusResult, error) { + queryArgs := url.Values{} + queryArgs.Set("user_id", userid) + queryArgs.Set("activation_code", activationCode) + + _, body, err := api.api.SignedCall("POST", + "/auth/v2/enroll_status", + queryArgs, + duoapi.UseTimeout) + + if err != nil { + return nil, err + } + ret := &EnrollStatusResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +// Preauth return type. +type PreauthResult struct { + StatResult + Response struct { + Result string + Status_Msg string + Enroll_Portal_Url string + Devices []struct { + Device string + Type string + Name string + Number string + Capabilities []string + } + } +} + +func PreauthUserId(userid string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("user_id", userid) + } +} + +func PreauthUsername(username string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("username", username) + } +} + +func PreauthIpAddr(ip string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("ipaddr", ip) + } +} + +func PreauthTrustedToken(trustedtoken string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("trusted_device_token", trustedtoken) + } +} + +// Duo's Preauth method. https://www.duosecurity.com/docs/authapi#/preauth +// options Optional values to include in the preauth call. +// Use PreauthUserId to specify the user_id parameter. +// Use PreauthUsername to specify the username parameter. You must +// specify PreauthUserId or PreauthUsername, but not both. +// Use PreauthIpAddr to include the ipaddr parameter, the ip address +// of the client attempting authroization. +// Use PreauthTrustedToken to specify the trusted_device_token parameter. +func (api *AuthApi) Preauth(options ...func(*url.Values)) (*PreauthResult, error) { + opts := url.Values{} + for _, o := range options { + o(&opts) + } + _, body, err := api.api.SignedCall("POST", "/auth/v2/preauth", opts, duoapi.UseTimeout) + if err != nil { + return nil, err + } + ret := &PreauthResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +func AuthUserId(userid string) func(*url.Values) { + return func(opts *url.Values) { + opts.Set("user_id", userid) + } +} + +func AuthUsername(username string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("username", username) + } +} + +func AuthIpAddr(ip string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("ipaddr", ip) + } +} + +func AuthAsync() func (*url.Values) { + return func(opts *url.Values) { + opts.Set("async", "1") + } +} + +func AuthDevice(device string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("device", device) + } +} + +func AuthType(type_ string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("type", type_) + } +} + +func AuthDisplayUsername(username string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("display_username", username) + } +} + +func AuthPushinfo(pushinfo string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("pushinfo", pushinfo) + } +} + +func AuthPasscode(passcode string) func (*url.Values) { + return func(opts *url.Values) { + opts.Set("passcode", passcode) + } +} + +// Auth return type. +type AuthResult struct { + StatResult + Response struct { + // Synchronous + Result string + Status string + Status_Msg string + // Asynchronous + Txid string + } +} + +// Duo's Auth method. https://www.duosecurity.com/docs/authapi#/auth +// Factor must be one of 'auto', 'push', 'passcode', 'sms' or 'phone'. +// Use AuthUserId to specify the user_id. +// Use AuthUsername to speicy the username. You must specify either AuthUserId +// or AuthUsername, but not both. +// Use AuthIpAddr to include the client's IP address. +// Use AuthAsync to toggle whether the call blocks for the user's response or not. +// If used asynchronously, get the auth status with the AuthStatus method. +// When using factor 'push', use AuthDevice to specify the device ID to push to. +// When using factor 'push', use AuthType to display some extra auth text to the user. +// When using factor 'push', use AuthDisplayUsername to display some extra text +// to the user. +// When using factor 'push', use AuthPushInfo to include some URL-encoded key/value +// pairs to display to the user. +// When using factor 'passcode', use AuthPasscode to specify the passcode entered +// by the user. +// When using factor 'sms' or 'phone', use AuthDevice to specify which device +// should receive the SMS or phone call. +func (api *AuthApi) Auth(factor string, options ...func(*url.Values)) (*AuthResult, error) { + params := url.Values{} + for _, o := range options { + o(¶ms) + } + params.Set("factor", factor) + + var apiOps []duoapi.DuoApiOption + if _, ok := params["async"]; ok == true { + apiOps = append(apiOps, duoapi.UseTimeout) + } + + _, body, err := api.api.SignedCall("POST", "/auth/v2/auth", params, apiOps...) + if err != nil { + return nil, err + } + ret := &AuthResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} + +// AuthStatus return type. +type AuthStatusResult struct { + StatResult + Response struct { + Result string + Status string + Status_Msg string + Trusted_Device_Token string + } +} + +// Duo's auth_status method. https://www.duosecurity.com/docs/authapi#/auth_status +// When using the Auth call in async mode, use this method to retrieve the +// result of the authentication attempt. +// txid is returned by the Auth call. +func (api *AuthApi) AuthStatus(txid string) (*AuthStatusResult, error) { + opts := url.Values{} + opts.Set("txid", txid) + _, body, err := api.api.SignedCall("GET", "/auth/v2/auth_status", opts) + if err != nil { + return nil, err + } + ret := &AuthStatusResult{} + if err = json.Unmarshal(body, ret); err != nil { + return nil, err + } + return ret, nil +} diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go new file mode 100644 index 0000000000..45fbbaf95f --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/authapi/authapi_test.go @@ -0,0 +1,497 @@ +package authapi + +import ( + "testing" + "fmt" + "strings" + "net/http" + "net/http/httptest" + "time" + "github.com/duosecurity/duo_api_golang" + ) + +func buildAuthApi(url string) *AuthApi { + ikey := "eyekey" + skey := "esskey" + host := strings.Split(url, "//")[1] + userAgent := "GoTestClient" + return NewAuthApi(*duoapi.NewDuoApi(ikey, + skey, + host, + userAgent, + duoapi.SetTimeout(1*time.Second), + duoapi.SetInsecure())) +} + +// Timeouts are set to 1 second. Take 15 seconds to respond and verify +// that the client times out. +func TestTimeout(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { + time.Sleep(15*time.Second) + })) + + duo := buildAuthApi(ts.URL) + + start := time.Now() + _, err := duo.Ping() + duration := time.Since(start) + if duration.Seconds() > 2 { + t.Error("Timeout took %d seconds", duration.Seconds()) + } + if err == nil { + t.Error("Expected timeout error.") + } +} + +// Test a successful ping request / response. +func TestPing(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "time": 1357020061, + "unexpected_parameter" : "blah" + } + }`) + })) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + result, err := duo.Ping() + if err != nil { + t.Error("Unexpected error from Ping call" + err.Error()) + } + if result.Stat != "OK" { + t.Error("Expected OK, but got " + result.Stat) + } + if result.Response.Time != 1357020061 { + t.Errorf("Expected 1357020061, but got %d", result.Response.Time) + } +} + +// Test a successful Check request / response. +func TestCheck(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "time": 1357020061 + } + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + result, err := duo.Check() + if err != nil { + t.Error("Failed TestCheck: " + err.Error()) + } + if result.Stat != "OK" { + t.Error("Expected OK, but got " + result.Stat) + } + if result.Response.Time != 1357020061 { + t.Errorf("Expected 1357020061, but got %d", result.Response.Time) + } +} + +// Test a successful logo request / response. +func TestLogo(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00" + + "\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00" + + "\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" + + "\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00" + + "\x00\x00\x00IEND\xaeB`\x82")) + })) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + _, err := duo.Logo() + if err != nil { + t.Error("Failed TestCheck: " + err.Error()) + } +} + +// Test a failure logo reqeust / response. +func TestLogoError(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + // Return a 400, as if the logo was not found. + w.WriteHeader(400) + fmt.Fprintln(w, ` + { + "stat": "FAIL", + "code": 40002, + "message": "Logo not found", + "message_detail": "Why u no have logo?" + }`) + })) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + res, err := duo.Logo() + if err != nil { + t.Error("Failed TestCheck: " + err.Error()) + } + if res.Stat != "FAIL" { + t.Error("Expected FAIL, but got " + res.Stat) + } + if res.Code == nil || *res.Code != 40002 { + t.Error("Unexpected response code.") + } + if res.Message == nil || *res.Message != "Logo not found" { + t.Error("Unexpected message.") + } + if res.Message_Detail == nil || *res.Message_Detail != "Why u no have logo?" { + t.Error("Unexpected message detail.") + } +} + +// Test a successful enroll request / response. +func TestEnroll(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + if r.FormValue("username") != "49c6c3097adb386048c84354d82ea63d" { + t.Error("TestEnroll failed to set 'username' query parameter:" + + r.RequestURI) + } + if r.FormValue("valid_secs") != "10" { + t.Error("TestEnroll failed to set 'valid_secs' query parameter: " + + r.RequestURI) + } + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "activation_barcode": "https://api-eval.duosecurity.com/frame/qr?value=8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA", + "activation_code": "duo://8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA", + "expiration": 1357020061, + "user_id": "DU94SWSN4ADHHJHF2HXT", + "username": "49c6c3097adb386048c84354d82ea63d" + } + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + result, err := duo.Enroll(EnrollUsername("49c6c3097adb386048c84354d82ea63d"), EnrollValidSeconds(10)) + if err != nil { + t.Error("Failed TestEnroll: " + err.Error()) + } + if result.Stat != "OK" { + t.Error("Expected OK, but got " + result.Stat) + } + if result.Response.Activation_Barcode != "https://api-eval.duosecurity.com/frame/qr?value=8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA" { + t.Error("Unexpected activation_barcode: " + result.Response.Activation_Barcode) + } + if result.Response.Activation_Code != "duo://8LIRa5danrICkhHtkLxi-cKLu2DWzDYCmBwBHY2YzW5ZYnYaRxA" { + t.Error("Unexpected activation code: " + result.Response.Activation_Code) + } + if result.Response.Expiration != 1357020061 { + t.Errorf("Unexpected expiration time: %d", result.Response.Expiration) + } + if result.Response.User_Id != "DU94SWSN4ADHHJHF2HXT" { + t.Error("Unexpected user id: " + result.Response.User_Id) + } + if result.Response.Username != "49c6c3097adb386048c84354d82ea63d" { + t.Error("Unexpected username: " + result.Response.Username) + } +} + +// Test a succesful enroll status request / response. +func TestEnrollStatus(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + if r.FormValue("user_id") != "49c6c3097adb386048c84354d82ea63d" { + t.Error("TestEnrollStatus failed to set 'user_id' query parameter:" + + r.RequestURI) + } + if r.FormValue("activation_code") != "10" { + t.Error("TestEnrollStatus failed to set 'activation_code' query parameter: " + + r.RequestURI) + } + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": "success" + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + result, err := duo.EnrollStatus("49c6c3097adb386048c84354d82ea63d", "10") + if err != nil { + t.Error("Failed TestEnrollStatus: " + err.Error()) + } + if result.Stat != "OK" { + t.Error("Expected OK, but got " + result.Stat) + } + if result.Response != "success" { + t.Error("Unexpected response: " + result.Response) + } +} + +// Test a successful preauth with user id. The client doesn't enforce api requirements, +// such as requiring only one of user id or username, but we'll cover the username +// in another test anyway. +func TestPreauthUserId(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + if r.FormValue("ipaddr") != "127.0.0.1" { + t.Error("TestPreauth failed to set 'ipaddr' query parameter:" + + r.RequestURI) + } + if r.FormValue("user_id") != "10" { + t.Error("TestEnrollStatus failed to set 'user_id' query parameter: " + + r.RequestURI) + } + if r.FormValue("trusted_device_token") != "l33t" { + t.Error("TestEnrollStatus failed to set 'trusted_device_token' query parameter: " + + r.RequestURI) + } + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "result": "auth", + "status_msg": "Account is active", + "devices": [ + { + "device": "DPFZRS9FB0D46QFTM891", + "type": "phone", + "number": "XXX-XXX-0100", + "name": "", + "capabilities": [ + "push", + "sms", + "phone" + ] + }, + { + "device": "DHEKH0JJIYC1LX3AZWO4", + "type": "token", + "name": "0" + } + ] + } + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + res, err := duo.Preauth(PreauthUserId("10"), PreauthIpAddr("127.0.0.1"), PreauthTrustedToken("l33t")) + if err != nil { + t.Error("Failed TestPreauthUserId: " + err.Error()) + } + if res.Stat != "OK" { + t.Error("Unexpected stat: " + res.Stat) + } + if res.Response.Result != "auth" { + t.Error("Unexpected response result: " + res.Response.Result) + } + if res.Response.Status_Msg != "Account is active" { + t.Error("Unexpected status message: " + res.Response.Status_Msg) + } + if len(res.Response.Devices) != 2 { + t.Errorf("Unexpected devices length: %d", len(res.Response.Devices)) + } + if res.Response.Devices[0].Device != "DPFZRS9FB0D46QFTM891" { + t.Error("Unexpected [0] device name: " + res.Response.Devices[0].Device) + } + if res.Response.Devices[0].Type != "phone" { + t.Error("Unexpected [0] device type: " + res.Response.Devices[0].Type) + } + if res.Response.Devices[0].Number != "XXX-XXX-0100" { + t.Error("Unexpected [0] device number: " + res.Response.Devices[0].Number) + } + if res.Response.Devices[0].Name != "" { + t.Error("Unexpected [0] devices name :" + res.Response.Devices[0].Name) + } + if len(res.Response.Devices[0].Capabilities) != 3 { + t.Errorf("Unexpected [0] device capabilities length: %d", len(res.Response.Devices[0].Capabilities)) + } + if res.Response.Devices[0].Capabilities[0] != "push" { + t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[0]) + } + if res.Response.Devices[0].Capabilities[1] != "sms" { + t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[1]) + } + if res.Response.Devices[0].Capabilities[2] != "phone" { + t.Error("Unexpected [0] device capability: " + res.Response.Devices[0].Capabilities[2]) + } + if res.Response.Devices[1].Device != "DHEKH0JJIYC1LX3AZWO4" { + t.Error("Unexpected [1] device name: " + res.Response.Devices[1].Device) + } + if res.Response.Devices[1].Type != "token" { + t.Error("Unexpected [1] device type: " + res.Response.Devices[1].Type) + } + if res.Response.Devices[1].Name != "0" { + t.Error("Unexpected [1] devices name :" + res.Response.Devices[1].Name) + } + if len(res.Response.Devices[1].Capabilities) != 0 { + t.Errorf("Unexpected [1] device capabilities length: %d", len(res.Response.Devices[1].Capabilities)) + } +} + +// Test preauth enroll with username, and an enroll response. +func TestPreauthEnroll(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + if r.FormValue("username") != "10" { + t.Error("TestEnrollStatus failed to set 'username' query parameter: " + + r.RequestURI) + } + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "enroll_portal_url": "https://api-3945ef22.duosecurity.com/portal?48bac5d9393fb2c2", + "result": "enroll", + "status_msg": "Enroll an authentication device to proceed" + } + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + res, err := duo.Preauth(PreauthUsername("10")) + if err != nil { + t.Error("Failed TestPreauthEnroll: " + err.Error()) + } + if res.Stat != "OK" { + t.Error("Unexpected stat: " + res.Stat) + } + if res.Response.Enroll_Portal_Url != "https://api-3945ef22.duosecurity.com/portal?48bac5d9393fb2c2" { + t.Error("Unexpected enroll portal URL: " + res.Response.Enroll_Portal_Url) + } + if res.Response.Result != "enroll" { + t.Error("Unexpected response result: " + res.Response.Result) + } + if res.Response.Status_Msg != "Enroll an authentication device to proceed" { + t.Error("Unexpected status msg: " + res.Response.Status_Msg) + } +} + +// Test an authentication request / response. This won't work against the Duo +// server, because the request parameters included are illegal. But we can +// verify that the go code sets the query parameters correctly. +func TestAuth(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + expected := map[string]string { + "username" : "username value", + "user_id" : "user_id value", + "factor" : "auto", + "ipaddr" : "40.40.40.10", + "async" : "1", + "device" : "primary", + "type" : "request", + "display_username" : "display username", + + } + for key, value := range expected { + if r.FormValue(key) != value { + t.Errorf("TestAuth failed to set '%s' query parameter: " + + r.RequestURI, key) + } + } + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "result": "allow", + "status": "allow", + "status_msg": "Success. Logging you in..." + } + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + res, err := duo.Auth("auto", + AuthUserId("user_id value"), + AuthUsername("username value"), + AuthIpAddr("40.40.40.10"), + AuthAsync(), + AuthDevice("primary"), + AuthType("request"), + AuthDisplayUsername("display username"), + ) + if err != nil { + t.Error("Failed TestAuth: " + err.Error()) + } + if res.Stat != "OK" { + t.Error("Unexpected stat: " + res.Stat) + } + if res.Response.Result != "allow" { + t.Error("Unexpected response result: " + res.Response.Result) + } + if res.Response.Status != "allow" { + t.Error("Unexpected response status: " + res.Response.Status) + } + if res.Response.Status_Msg != "Success. Logging you in..." { + t.Error("Unexpected response status msg: " + res.Response.Status_Msg) + } +} + +// Test AuthStatus request / response. +func TestAuthStatus(t *testing.T) { + ts := httptest.NewTLSServer( + http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + expected := map[string]string { + "txid" : "4", + } + for key, value := range expected { + if r.FormValue(key) != value { + t.Errorf("TestAuthStatus failed to set '%s' query parameter: " + + r.RequestURI, key) + } + } + fmt.Fprintln(w, ` + { + "stat": "OK", + "response": { + "result": "waiting", + "status": "pushed", + "status_msg": "Pushed a login request to your phone..." + } + }`)})) + defer ts.Close() + + duo := buildAuthApi(ts.URL) + + res, err := duo.AuthStatus("4") + if err != nil { + t.Error("Failed TestAuthStatus: " + err.Error()) + } + + if res.Stat != "OK" { + t.Error("Unexpected stat: " + res.Stat) + } + if res.Response.Result != "waiting" { + t.Error("Unexpected response result: " + res.Response.Result) + } + if res.Response.Status != "pushed" { + t.Error("Unexpected response status: " + res.Response.Status) + } + if res.Response.Status_Msg != "Pushed a login request to your phone..." { + t.Error("Unexpected response status msg: " + res.Response.Status_Msg) + } +} diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go new file mode 100644 index 0000000000..01278457e3 --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duo_test.go @@ -0,0 +1,156 @@ +package duoapi + +import ( + "testing" + "net/url" + "strings" +) + +func TestCanonicalize(t *testing.T) { + values := url.Values{} + values.Set("username", "H ell?o") + values.Set("password", "H-._~i") + values.Add("password", "A(!'*)") + params_str := canonicalize("post", + "API-XXX.duosecurity.COM", + "/auth/v2/ping", + values, + "5") + params := strings.Split(params_str, "\n") + if len(params) != 5 { + t.Error("Expected 5 parameters, but got " + string(len(params))) + } + if params[1] != string("POST") { + t.Error("Expected POST, but got " + params[1]) + } + if params[2] != string("api-xxx.duosecurity.com") { + t.Error("Expected api-xxx.duosecurity.com, but got " + params[2]) + } + if params[3] != string("/auth/v2/ping") { + t.Error("Expected /auth/v2/ping, but got " + params[3]) + } + if params[4] != string("password=A%28%21%27%2A%29&password=H-._~i&username=H%20ell%3Fo") { + t.Error("Expected sorted escaped params, but got " + params[4]) + } +} + +func encodeAndValidate(t *testing.T, input url.Values, output string) { + values := url.Values{} + for key, val := range input { + values.Set(key, val[0]) + } + params_str := canonicalize("post", + "API-XXX.duosecurity.com", + "/auth/v2/ping", + values, + "5") + params := strings.Split(params_str, "\n") + if params[4] != output { + t.Error("Mismatch\n" + output + "\n" + params[4]) + } + +} + +func TestSimple(t *testing.T) { + values := url.Values{} + values.Set("realname", "First Last") + values.Set("username", "root") + + encodeAndValidate(t, values, "realname=First%20Last&username=root") +} + +func TestZero(t *testing.T) { + values := url.Values{} + encodeAndValidate(t, values, "") +} + +func TestOne(t *testing.T) { + values := url.Values{} + values.Set("realname", "First Last") + encodeAndValidate(t, values, "realname=First%20Last") +} + +func TestPrintableAsciiCharaceters(t *testing.T) { + values := url.Values{} + values.Set("digits", "0123456789") + values.Set("letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + values.Set("punctuation", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~") + values.Set("whitespace", "\t\n\x0b\x0c\r ") + encodeAndValidate(t, values, "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20") +} + +func TestSortOrderWithCommonPrefix(t *testing.T) { + values := url.Values{} + values.Set("foo", "1") + values.Set("foo_bar", "2") + encodeAndValidate(t, values, "foo=1&foo_bar=2") +} + +func TestUnicodeFuzzValues(t *testing.T) { + values := url.Values{} + values.Set("bar", "⠕ꪣ㟏䮷㛩찅暎腢슽ꇱ") + values.Set("baz", "ෳ蒽噩馅뢤갺篧潩鍊뤜") + values.Set("foo", "퓎훖礸僀訠輕ﴋ耤岳왕") + values.Set("qux", "讗졆-芎茚쳊ꋔ谾뢲馾") + encodeAndValidate(t, values, "bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE") +} + +func TestUnicodeFuzzKeysAndValues(t *testing.T) { + values := url.Values{} + values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰", + "ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐") + values.Set("瑉繋쳻姿﹟获귌逌쿑砓", + "趷倢鋓䋯⁽蜰곾嘗ॆ丰") + values.Set("瑰錔逜麮䃘䈁苘豰ᴱꁂ", + "៙ந鍘꫟ꐪ䢾ﮖ濩럿㋳") + values.Set("싅Ⱍ☠㘗隳F蘅⃨갡头", + "ﮩ䆪붃萋☕㹮攭ꢵ핫U") + encodeAndValidate(t, values, "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU") +} + +func TestSign(t *testing.T) { + values := url.Values{} + values.Set("realname", "First Last") + values.Set("username", "root") + res := sign("DIWJ8X6AEYOR5OMC6TQ1", + "Zh5eGmUq9zpfQnyUIu5OL9iWoMMv5ZNmk3zLJ4Ep", + "POST", + "api-XXXXXXXX.duosecurity.com", + "/accounts/v1/account/list", + "Tue, 21 Aug 2012 17:29:18 -0000", + values) + if res != "Basic RElXSjhYNkFFWU9SNU9NQzZUUTE6MmQ5N2Q2MTY2MzE5Nzgx" + + "YjVhM2EwN2FmMzlkMzY2ZjQ5MTIzNGVkYw==" { + t.Error("Signature did not produce output documented at " + + "https://www.duosecurity.com/docs/authapi :(") + } +} + +func TestV2Canonicalize(t *testing.T) { + values := url.Values{} + values.Set("䚚⡻㗐軳朧倪ࠐ킑È셰", + "ཅ᩶㐚敌숿鬉ꯢ荃ᬧ惐") + values.Set("瑉繋쳻姿﹟获귌逌쿑砓", + "趷倢鋓䋯⁽蜰곾嘗ॆ丰") + values.Set("瑰錔逜麮䃘䈁苘豰ᴱꁂ", + "៙ந鍘꫟ꐪ䢾ﮖ濩럿㋳") + values.Set("싅Ⱍ☠㘗隳F蘅⃨갡头", + "ﮩ䆪붃萋☕㹮攭ꢵ핫U") + canon := canonicalize( + "PoSt", + "foO.BAr52.cOm", + "/Foo/BaR2/qux", + values, + "Fri, 07 Dec 2012 17:18:00 -0000") + expected := "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU" + if canon != expected { + t.Error("Mismatch!\n" + expected + "\n" + canon) + } +} + +func TestNewDuo(t *testing.T) { + duo := NewDuoApi("ABC", "123", "api-XXXXXXX.duosecurity.com", "go-client") + if duo == nil { + t.Fatal("Failed to create a new Duo Api") + } +} diff --git a/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go new file mode 100644 index 0000000000..1c9d3ac6ef --- /dev/null +++ b/Godeps/_workspace/src/github.com/duosecurity/duo_api_golang/duoapi.go @@ -0,0 +1,355 @@ +package duoapi + +import ( + "strings" + "net/url" + "sort" + "crypto/hmac" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/base64" + "net/http" + "time" + "io/ioutil" +) +var spaceReplacer *strings.Replacer = strings.NewReplacer("+", "%20") + +func canonParams(params url.Values) string { + // Values must be in sorted order + for key, val := range params { + sort.Strings(val) + params[key] = val + } + // Encode will place Keys in sorted order + ordered_params := params.Encode() + // Encoder turns spaces into +, but we need %XX escaping + return spaceReplacer.Replace(ordered_params) +} + +func canonicalize(method string, + host string, + uri string, + params url.Values, + date string) string { + var canon [5]string + canon[0] = date + canon[1] = strings.ToUpper(method) + canon[2] = strings.ToLower(host) + canon[3] = uri + canon[4] = canonParams(params) + return strings.Join(canon[:], "\n") +} + +func sign(ikey string, + skey string, + method string, + host string, + uri string, + date string, + params url.Values) string { + canon := canonicalize(method, host, uri, params, date) + mac := hmac.New(sha1.New, []byte(skey)) + mac.Write([]byte(canon)) + sig := hex.EncodeToString(mac.Sum(nil)) + auth := ikey + ":" + sig + return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) +} + +type DuoApi struct { + ikey string + skey string + host string + userAgent string + apiClient *http.Client + authClient *http.Client +} + +type apiOptions struct { + timeout time.Duration + insecure bool +} + +// Optional parameter for NewDuoApi, used to configure timeouts on API calls. +func SetTimeout(timeout time.Duration) func(*apiOptions) { + return func(opts *apiOptions) { + opts.timeout = timeout + return + } +} + +// Optional parameter for testing only. Bypasses all TLS certificate validation. +func SetInsecure() func(*apiOptions) { + return func(opts *apiOptions) { + opts.insecure = true + } +} + +// Build an return a DuoApi struct. +// ikey is your Duo integration key +// skey is your Duo integration secret key +// host is your Duo host +// userAgent allows you to specify the user agent string used when making +// the web request to Duo. +// options are optional parameters. Use SetTimeout() to specify a timeout value +// for Rest API calls. +// +// Example: duoapi.NewDuoApi(ikey,skey,host,userAgent,duoapi.SetTimeout(10*time.Second)) +func NewDuoApi(ikey string, + skey string, + host string, + userAgent string, + options ...func(*apiOptions)) (*DuoApi) { + opts := apiOptions{} + for _, o := range options { + o(&opts) + } + + // Certificate pinning + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM([]byte(duoPinnedCert)) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + InsecureSkipVerify: opts.insecure, + }, + } + return &DuoApi{ + ikey: ikey, + skey: skey, + host: host, + userAgent: userAgent, + apiClient: &http.Client{ + Timeout: opts.timeout, + Transport: tr, + }, + authClient: &http.Client{ + Transport: tr, + }, + } +} + +type requestOptions struct { + timeout bool +} + +type DuoApiOption func(*requestOptions) + +// Pass to Request or SignedRequest to configure a timeout on the request +func UseTimeout(opts *requestOptions) { + opts.timeout = true +} + +func (duoapi *DuoApi) buildOptions(options ...DuoApiOption) (*requestOptions) { + opts := &requestOptions{} + for _, o := range options { + o(opts) + } + return opts +} + +// Make an unsigned Duo Rest API call. See Duo's online documentation +// for the available REST API's. +// method is POST or GET +// uri is the URI of the Duo Rest call +// params HTTP query parameters to include in the call. +// options Optional parameters. Use UseTimeout to toggle whether the +// Duo Rest API call should timeout or not. +// +// Example: duo.Call("GET", "/auth/v2/ping", nil, duoapi.UseTimeout) +func (duoapi *DuoApi) Call(method string, + uri string, + params url.Values, + options ...DuoApiOption) (*http.Response, []byte, error) { + opts := duoapi.buildOptions(options...) + + client := duoapi.authClient + if opts.timeout { + client = duoapi.apiClient + } + + url := url.URL{ + Scheme: "https", + Host: duoapi.host, + Path: uri, + RawQuery: params.Encode(), + } + request, err := http.NewRequest(method, url.String(), nil) + if err != nil { + return nil, nil, err + } + resp, err := client.Do(request) + var body []byte + if err == nil { + body, err = ioutil.ReadAll(resp.Body) + resp.Body.Close() + } + return resp, body, err +} + +// Make a signed Duo Rest API call. See Duo's online documentation +// for the available REST API's. +// method is POST or GET +// uri is the URI of the Duo Rest call +// params HTTP query parameters to include in the call. +// options Optional parameters. Use UseTimeout to toggle whether the +// Duo Rest API call should timeout or not. +// +// Example: duo.SignedCall("GET", "/auth/v2/check", nil, duoapi.UseTimeout) +func (duoapi *DuoApi) SignedCall(method string, + uri string, + params url.Values, + options ...DuoApiOption) (*http.Response, []byte, error) { + opts := duoapi.buildOptions(options...) + + now := time.Now().UTC().Format(time.RFC1123Z) + auth_sig := sign(duoapi.ikey, duoapi.skey, method, duoapi.host, uri, now, params) + + url := url.URL{ + Scheme: "https", + Host: duoapi.host, + Path: uri, + RawQuery: params.Encode(), + } + request, err := http.NewRequest(method, url.String(), nil) + if err != nil { + return nil, nil, err + } + request.Header.Set("Authorization", auth_sig) + request.Header.Set("Date", now) + + client := duoapi.authClient + if opts.timeout { + client = duoapi.apiClient + } + resp, err := client.Do(request) + var body []byte + if err == nil { + body, err = ioutil.ReadAll(resp.Body) + resp.Body.Close() + } + return resp, body, err +} + +const duoPinnedCert string = "subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Assured ID Root CA\n" + +"-----BEGIN CERTIFICATE-----\n" + +"MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl\n" + +"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + +"d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv\n" + +"b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG\n" + +"EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl\n" + +"cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi\n" + +"MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c\n" + +"JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP\n" + +"mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+\n" + +"wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4\n" + +"VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/\n" + +"AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB\n" + +"AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" + +"BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun\n" + +"pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC\n" + +"dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf\n" + +"fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm\n" + +"NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx\n" + +"H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe\n" + +"+o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g==\n" + +"-----END CERTIFICATE-----\n" + +"\n" + +"subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Global Root CA\n" + +"-----BEGIN CERTIFICATE-----\n" + +"MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh\n" + +"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + +"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD\n" + +"QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT\n" + +"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + +"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG\n" + +"9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB\n" + +"CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97\n" + +"nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt\n" + +"43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P\n" + +"T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4\n" + +"gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO\n" + +"BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR\n" + +"TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw\n" + +"DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr\n" + +"hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg\n" + +"06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF\n" + +"PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls\n" + +"YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk\n" + +"CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=\n" + +"-----END CERTIFICATE-----\n" + +"\n" + +"subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA\n" + +"-----BEGIN CERTIFICATE-----\n" + +"MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs\n" + +"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + +"d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j\n" + +"ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL\n" + +"MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3\n" + +"LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug\n" + +"RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm\n" + +"+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW\n" + +"PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM\n" + +"xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB\n" + +"Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3\n" + +"hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg\n" + +"EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF\n" + +"MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA\n" + +"FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec\n" + +"nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z\n" + +"eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF\n" + +"hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2\n" + +"Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe\n" + +"vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep\n" + +"+OkuE6N36B9K\n" + +"-----END CERTIFICATE-----\n" + +"\n" + +"subject= /C=US/O=SecureTrust Corporation/CN=SecureTrust CA\n" + +"-----BEGIN CERTIFICATE-----\n" + +"MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI\n" + +"MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x\n" + +"FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz\n" + +"MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv\n" + +"cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN\n" + +"AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz\n" + +"Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO\n" + +"0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao\n" + +"wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj\n" + +"7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS\n" + +"8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT\n" + +"BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB\n" + +"/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg\n" + +"JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC\n" + +"NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3\n" + +"6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/\n" + +"3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm\n" + +"D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS\n" + +"CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR\n" + +"3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE=\n" + +"-----END CERTIFICATE-----\n" + +"\n" + +"subject= /C=US/O=SecureTrust Corporation/CN=Secure Global CA\n" + +"-----BEGIN CERTIFICATE-----\n" + +"MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK\n" + +"MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x\n" + +"GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx\n" + +"MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg\n" + +"Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG\n" + +"SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ\n" + +"iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa\n" + +"/FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ\n" + +"jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI\n" + +"HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7\n" + +"sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w\n" + +"gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF\n" + +"MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw\n" + +"KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG\n" + +"AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L\n" + +"URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO\n" + +"H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm\n" + +"I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY\n" + +"iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc\n" + +"f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW\n" + +"-----END CERTIFICATE-----\n" From 7b6547abf74021dda7d7f9c489d0332320201c44 Mon Sep 17 00:00:00 2001 From: Bradley Girardeau Date: Thu, 30 Jul 2015 17:16:53 -0700 Subject: [PATCH 12/12] Clean up naming and add documentation --- builtin/credential/ldap/backend.go | 2 +- builtin/credential/userpass/backend.go | 2 +- helper/mfa/duo/duo.go | 10 +++++++++- helper/mfa/mfa.go | 27 +++++++++++++++++++++++--- helper/mfa/mfa_test.go | 6 +++++- website/source/docs/auth/mfa.html.md | 12 +++++++++++- 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go index c65f80f75d..77e58d38b0 100644 --- a/builtin/credential/ldap/backend.go +++ b/builtin/credential/ldap/backend.go @@ -24,7 +24,7 @@ func Backend() *framework.Backend { "groups/*", "users/*", }, - mfa.MFAPathsSpecial()..., + mfa.MFARootPaths()..., ), Unauthenticated: []string{ diff --git a/builtin/credential/userpass/backend.go b/builtin/credential/userpass/backend.go index a3e35211cd..d2e62ab52a 100644 --- a/builtin/credential/userpass/backend.go +++ b/builtin/credential/userpass/backend.go @@ -19,7 +19,7 @@ func Backend() *framework.Backend { Root: append([]string{ "users/*", }, - mfa.MFAPathsSpecial()..., + mfa.MFARootPaths()..., ), Unauthenticated: []string{ diff --git a/helper/mfa/duo/duo.go b/helper/mfa/duo/duo.go index 3313928a19..df25316cbf 100644 --- a/helper/mfa/duo/duo.go +++ b/helper/mfa/duo/duo.go @@ -1,3 +1,6 @@ +// Package duo provides a Duo MFA handler to authenticate users +// with Duo. This handler is registered as the "duo" type in +// mfa_config. package duo import ( @@ -9,6 +12,7 @@ import ( "github.com/hashicorp/vault/logical/framework" ) +// DuoPaths returns path functions to configure Duo. func DuoPaths() []*framework.Path { return []*framework.Path{ pathDuoConfig(), @@ -16,13 +20,17 @@ func DuoPaths() []*framework.Path { } } -func DuoPathsSpecial() []string { +// DuoRootPaths returns the paths that are used to configure Duo. +func DuoRootPaths() []string { return []string { "duo/access", "duo/config", } } +// DuoHandler interacts with the Duo Auth API to authenticate a user +// login request. If successful, the original response from the login +// backend is returned. func DuoHandler(req *logical.Request, d *framework.FieldData, resp *logical.Response) ( *logical.Response, error) { duoConfig, err := GetDuoConfig(req) diff --git a/helper/mfa/mfa.go b/helper/mfa/mfa.go index 1e87c8b26b..2cb28381f9 100644 --- a/helper/mfa/mfa.go +++ b/helper/mfa/mfa.go @@ -1,3 +1,15 @@ +// Package mfa provides wrappers to add multi-factor authentication +// to any auth backend. +// +// To add MFA to a backend, replace its login path with the +// paths returned by MFAPaths and add the additional root +// paths returned by MFARootPaths. The backend provides +// the username to the MFA wrapper in Auth.Metadata['username']. +// +// To add an additional MFA type, create a subpackage that +// implements [Type]Paths, [Type]RootPaths, and [Type]Handler +// functions and add them to MFAPaths, MFARootPaths, and +// handlers respectively. package mfa import ( @@ -6,18 +18,26 @@ import ( "github.com/hashicorp/vault/logical/framework" ) +// MFAPaths returns paths to wrap the original login path and configure MFA. +// When adding MFA to a backend, these paths should be included instead of +// the login path in Backend.Paths. func MFAPaths(originalBackend *framework.Backend, loginPath *framework.Path) []*framework.Path { var b backend b.Backend = originalBackend return append(duo.DuoPaths(), pathMFAConfig(&b), wrapLoginPath(&b, loginPath)) } -func MFAPathsSpecial() []string { - return append(duo.DuoPathsSpecial(), "mfa_config") +// MFARootPaths returns path strings used to configure MFA. When adding MFA +// to a backend, these paths should be included in +// Backend.PathsSpecial.Root. +func MFARootPaths() []string { + return append(duo.DuoRootPaths(), "mfa_config") } +// HandlerFunc is the callback called to handle MFA for a login request. type HandlerFunc func (*logical.Request, *framework.FieldData, *logical.Response) (*logical.Response, error) +// handlers maps each supported MFA type to its handler. var handlers = map[string]HandlerFunc{ "duo": duo.DuoHandler, } @@ -35,7 +55,7 @@ func wrapLoginPath(b *backend, loginPath *framework.Path) *framework.Path { Type: framework.TypeString, Description: "Multi-factor auth method to use (optional)", } - // wrap write callback to do duo two factor after auth + // wrap write callback to do MFA after auth loginHandler := loginPath.Callbacks[logical.WriteOperation] loginPath.Callbacks[logical.WriteOperation] = b.wrapLoginHandler(loginHandler) return loginPath @@ -55,6 +75,7 @@ func (b *backend) wrapLoginHandler(loginHandler framework.OperationFunc) framewo return resp, nil } + // perform multi-factor authentication if type supported handler, ok := handlers[mfa_config.Type] if ok { return handler(req, d, resp) diff --git a/helper/mfa/mfa_test.go b/helper/mfa/mfa_test.go index c85e6a5b6d..197ed20b3e 100644 --- a/helper/mfa/mfa_test.go +++ b/helper/mfa/mfa_test.go @@ -8,13 +8,17 @@ import ( "github.com/hashicorp/vault/logical/framework" ) +// MakeTestBackend creates a simple MFA enabled backend. +// Login (before MFA) always succeeds with policy "foo". +// An MFA "test" type is added to mfa.handlers that succeeds +// if MFA method is "accept", otherwise it rejects. func MakeTestBackend() *framework.Backend { handlers["test"] = testMFAHandler b := &framework.Backend{ Help: "", PathsSpecial: &logical.Paths{ - Root: MFAPathsSpecial(), + Root: MFARootPaths(), Unauthenticated: []string{ "login", }, diff --git a/website/source/docs/auth/mfa.html.md b/website/source/docs/auth/mfa.html.md index 382af66de9..a58c78cd43 100644 --- a/website/source/docs/auth/mfa.html.md +++ b/website/source/docs/auth/mfa.html.md @@ -55,7 +55,9 @@ To enable MFA for a supported backend, the MFA type must be set in `mfa_config`. $ vault write auth/userpass/mfa_config type=duo ``` -This enables the Duo MFA type, which is currently the only MFA type supported. +This enables the Duo MFA type, which is currently the only MFA type supported. The username +used for MFA is the same as the login username, unless the backend or MFA type provide +options to behave differently (see Duo configuration below). ### Duo @@ -79,4 +81,12 @@ $ vault write auth/[mount]/duo/config \ username_format="%s" ``` +`user_agent` is the user agent to use when connecting to Duo. + +`username_format` controls how the username used to login is +transformed before authenticating with Duo. This field is a format string +that is passed the original username as its first argument and outputs +the new username. For example "%s@example.com" would append "@example.com" +to the provided username before connecting to Duo. + More information can be found through the CLI `path-help` command.