Merge pull request #46 from rackspace/reauth
Implement Reauth capability
diff --git a/acceptance/08-reauthentication.go b/acceptance/08-reauthentication.go
new file mode 100644
index 0000000..8b198c7
--- /dev/null
+++ b/acceptance/08-reauthentication.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "fmt"
+ "flag"
+ "github.com/rackspace/gophercloud"
+)
+
+var quiet = flag.Bool("quiet", false, "Quiet mode for acceptance testing. $? non-zero on error though.")
+var rgn = flag.String("r", "DFW", "Datacenter region to interrogate.")
+
+func main() {
+ provider, username, password := getCredentials()
+ flag.Parse()
+
+ // Authenticate initially against the service.
+ auth, err := gophercloud.Authenticate(
+ provider,
+ gophercloud.AuthOptions{
+ Username: username,
+ Password: password,
+ AllowReauth: true, // This enables reauthentication.
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ // Cache our initial authentication token.
+ token1 := auth.AuthToken()
+
+ // Acquire access to the cloud servers API.
+ servers, err := gophercloud.ServersApi(auth, gophercloud.ApiCriteria{
+ Name: "cloudServersOpenStack",
+ Region: *rgn,
+ VersionId: "2",
+ UrlChoice: gophercloud.PublicURL,
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // Just to confirm everything works, we should be able to list images without error.
+ _, err = servers.ListImages()
+ if err != nil {
+ panic(err)
+ }
+
+ // Revoke our current authentication token.
+ auth.Revoke(auth.AuthToken())
+
+ // Attempt to list images again. This should _succeed_, because we enabled re-authentication.
+ _, err = servers.ListImages()
+ if err != nil {
+ panic(err)
+ }
+
+ // However, our new authentication token should differ.
+ token2 := auth.AuthToken()
+
+ if !*quiet {
+ fmt.Println("Old authentication token: ", token1)
+ fmt.Println("New authentication token: ", token2)
+ }
+}
diff --git a/authenticate.go b/authenticate.go
index 8b87e44..07a5611 100644
--- a/authenticate.go
+++ b/authenticate.go
@@ -16,6 +16,13 @@
// The TenantId field is optional for the Identity V2 API.
TenantId string
+
+ // AllowReauth should be set to true if you grant permission for Gophercloud to cache
+ // your credentials in memory, and to allow Gophercloud to attempt to re-authenticate
+ // automatically if/when your token expires. If you set it to false, it will not cache
+ // these settings, but re-authentication will not be possible. This setting defaults
+ // to false.
+ AllowReauth bool
}
// AuthContainer provides a JSON encoding wrapper for passing credentials to the Identity
@@ -44,6 +51,9 @@
Token Token
ServiceCatalog []CatalogEntry
User User
+ provider Provider `json:"-"`
+ options AuthOptions `json:"-"`
+ context *Context `json:"-"`
}
// Token encapsulates an authentication token and when it expires. It also includes
@@ -85,30 +95,18 @@
VersionId, VersionInfo, VersionList string
}
-// Authenticate() grants access to the OpenStack-compatible provider API.
-//
-// Providers are identified through a unique key string.
-// See the RegisterProvider() method for more details.
-//
-// The supplied AuthOptions instance allows the client to specify only those credentials
-// relevant for the authentication request. At present, support exists for OpenStack
-// Identity V2 API only; support for V3 will become available as soon as documentation for it
-// becomes readily available.
-//
-// For Identity V2 API requirements, you must provide at least the Username and Password
-// options. The TenantId field is optional, and defaults to "".
-func (c *Context) Authenticate(provider string, options AuthOptions) (*Access, error) {
+// papersPlease contains the common logic between authentication and re-authentication.
+// The name, obviously a joke on the process of authentication, was chosen because
+// of how many other entities exist in the program containing the word Auth or Authorization.
+// I didn't need another one.
+func (c *Context) papersPlease(p Provider, options AuthOptions) (*Access, error) {
var access *Access
- p, err := c.ProviderByName(provider)
- if err != nil {
- return nil, err
- }
if (options.Username == "") || (options.Password == "") {
return nil, ErrCredentials
}
- err = perigee.Post(p.AuthEndpoint, perigee.Options{
+ err := perigee.Post(p.AuthEndpoint, perigee.Options{
CustomClient: c.httpClient,
ReqBody: &AuthContainer{
Auth: Auth{
@@ -125,9 +123,50 @@
&access,
},
})
+ if err == nil {
+ access.options = options
+ access.provider = p
+ access.context = c
+ }
return access, err
}
+// Authenticate() grants access to the OpenStack-compatible provider API.
+//
+// Providers are identified through a unique key string.
+// See the RegisterProvider() method for more details.
+//
+// The supplied AuthOptions instance allows the client to specify only those credentials
+// relevant for the authentication request. At present, support exists for OpenStack
+// Identity V2 API only; support for V3 will become available as soon as documentation for it
+// becomes readily available.
+//
+// For Identity V2 API requirements, you must provide at least the Username and Password
+// options. The TenantId field is optional, and defaults to "".
+func (c *Context) Authenticate(provider string, options AuthOptions) (*Access, error) {
+ p, err := c.ProviderByName(provider)
+ if err != nil {
+ return nil, err
+ }
+ return c.papersPlease(p, options)
+}
+
+// Reauthenticate attempts to reauthenticate using the configured access credentials, if
+// allowed. This method takes no action unless your AuthOptions has the AllowReauth flag
+// set to true.
+func (a *Access) Reauthenticate() error {
+ var other *Access
+ var err error
+
+ if a.options.AllowReauth {
+ other, err = a.context.papersPlease(a.provider, a.options)
+ if err == nil {
+ *a = *other
+ }
+ }
+ return err
+}
+
// See AccessProvider interface definition for details.
func (a *Access) FirstEndpointUrlByCriteria(ac ApiCriteria) string {
ep := FindFirstEndpointByCriteria(a.ServiceCatalog, ac)
@@ -139,3 +178,15 @@
func (a *Access) AuthToken() string {
return a.Token.Id
}
+
+// See AccessProvider interface definition for details.
+func (a *Access) Revoke(tok string) error {
+ url := a.provider.AuthEndpoint + "/" + tok
+ err := perigee.Delete(url, perigee.Options{
+ MoreHeaders: map[string]string{
+ "X-Auth-Token": a.AuthToken(),
+ },
+ OkCodes: []int{204},
+ })
+ return err
+}
diff --git a/authenticate_test.go b/authenticate_test.go
index a19fec4..1d4d1f5 100644
--- a/authenticate_test.go
+++ b/authenticate_test.go
@@ -53,7 +53,7 @@
`
func TestAuthProvider(t *testing.T) {
- tt := newTransport()
+ tt := newTransport().WithResponse(SUCCESSFUL_RESPONSE)
c := TestContext().UseCustomClient(&http.Client{
Transport: tt,
})
@@ -86,7 +86,7 @@
}
func TestTenantIdEncoding(t *testing.T) {
- tt := newTransport()
+ tt := newTransport().WithResponse(SUCCESSFUL_RESPONSE)
c := TestContext().
UseCustomClient(&http.Client{
Transport: tt,
@@ -126,7 +126,7 @@
func TestUserNameAndPassword(t *testing.T) {
c := TestContext().
WithProvider("provider", Provider{AuthEndpoint: "http://localhost/"}).
- UseCustomClient(&http.Client{Transport: newTransport()})
+ UseCustomClient(&http.Client{Transport: newTransport().WithResponse(SUCCESSFUL_RESPONSE)})
credentials := []AuthOptions{
AuthOptions{},
@@ -212,3 +212,28 @@
return
}
}
+
+func TestAuthenticationNeverReauths(t *testing.T) {
+ tt := newTransport().WithError(401)
+ c := TestContext().
+ UseCustomClient(&http.Client{Transport: tt}).
+ WithProvider("provider", Provider{AuthEndpoint: "http://localhost"})
+
+ _, err := c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+ if err == nil {
+ t.Error("Expected an error from a 401 Unauthorized response")
+ return
+ }
+
+ rc, _ := ActualResponseCode(err)
+ if rc != 401 {
+ t.Error("Expected a 401 error code")
+ return
+ }
+
+ err = tt.VerifyCalls(t, 1)
+ if err != nil {
+ // Test object already flagged.
+ return
+ }
+}
diff --git a/context.go b/context.go
index a946468..ebad36c 100644
--- a/context.go
+++ b/context.go
@@ -12,6 +12,10 @@
AuthEndpoint string
}
+// ReauthHandlerFunc functions are responsible for somehow performing the task of
+// reauthentication.
+type ReauthHandlerFunc func(AccessProvider) error
+
// Context structures encapsulate Gophercloud-global state in a manner which
// facilitates easier unit testing. As a user of this SDK, you'll never
// have to use this structure, except when contributing new code to the SDK.
@@ -21,6 +25,17 @@
// httpClient refers to the current HTTP client interface to use.
httpClient *http.Client
+
+ // reauthHandler provides the functionality needed to re-authenticate
+ // if that feature is enabled. Note: in order to allow for automatic
+ // re-authentication, the Context object will need to remember your
+ // username, password, and tenant ID as provided in the initial call
+ // to Authenticate(). If you do not desire this, you'll need to handle
+ // reauthentication yourself through other means. Two methods exist:
+ // the first approach is to just handle errors yourself at the application
+ // layer, and the other is through a custom reauthentication handler
+ // set through the WithReauthHandler() method.
+ reauthHandler ReauthHandlerFunc
}
// TestContext yields a new Context instance, pre-initialized with a barren
@@ -34,6 +49,9 @@
return &Context{
providerMap: make(map[string]Provider),
httpClient: &http.Client{},
+ reauthHandler: func(acc AccessProvider) error {
+ return acc.Reauthenticate()
+ },
}
}
@@ -92,3 +110,15 @@
return gcp, nil
}
+
+// WithReauthHandler configures the context to handle reauthentication attempts using the supplied
+// funtion. By default, reauthentication happens by invoking Authenticate(), which is unlikely to be
+// useful in a unit test.
+//
+// Do not confuse this function with WithReauth()! Although they work together to support reauthentication,
+// WithReauth() actually contains the decision-making logic to determine when to perform a reauth,
+// while WithReauthHandler() is used to configure what a reauth actually entails.
+func (c *Context) WithReauthHandler(f ReauthHandlerFunc) *Context {
+ c.reauthHandler = f
+ return c
+}
diff --git a/errors.go b/errors.go
index f113446..5ea3991 100644
--- a/errors.go
+++ b/errors.go
@@ -30,3 +30,8 @@
// exists in the service catalog. This can also happen if your tenant lacks
// adequate permissions to access a given endpoint.
var ErrEndpoint = fmt.Errorf("Missing endpoint, or insufficient privileges to access endpoint")
+
+// ErrError errors happen when you attempt to discover the response code
+// responsible for a previous request bombing with an error, but pass in an
+// error interface which doesn't belong to the web client.
+var ErrError = fmt.Errorf("Attempt to solicit actual HTTP response code from error entity which doesn't know")
diff --git a/global_context.go b/global_context.go
index c89ac17..9977aa8 100644
--- a/global_context.go
+++ b/global_context.go
@@ -1,5 +1,9 @@
package gophercloud
+import (
+ "github.com/racker/perigee"
+)
+
// globalContext is the, well, "global context."
// Most of this SDK is written in a manner to facilitate easier testing,
// which doesn't require all the configuration a real-world application would require.
@@ -46,3 +50,13 @@
func ServersApi(acc AccessProvider, criteria ApiCriteria) (CloudServersProvider, error) {
return globalContext.ServersApi(acc, criteria)
}
+
+// ActualResponseCode inspects a returned error, and discovers the actual response actual
+// response code that caused the error to be raised.
+func ActualResponseCode(e error) (int, error) {
+ err, ok := e.(*perigee.UnexpectedResponseCodeError)
+ if !ok {
+ return 0, ErrError
+ }
+ return err.Actual, nil
+}
\ No newline at end of file
diff --git a/images.go b/images.go
index 9126b93..cc3f6e2 100644
--- a/images.go
+++ b/images.go
@@ -7,13 +7,15 @@
// See the CloudImagesProvider interface for details.
func (gsp *genericServersProvider) ListImages() ([]Image, error) {
var is []Image
- url := gsp.endpoint + "/images"
- err := perigee.Get(url, perigee.Options{
- CustomClient: gsp.context.httpClient,
- Results: &struct{ Images *[]Image }{&is},
- MoreHeaders: map[string]string{
- "X-Auth-Token": gsp.access.AuthToken(),
- },
+ err := gsp.context.WithReauth(gsp.access, func() error {
+ url := gsp.endpoint + "/images"
+ return perigee.Get(url, perigee.Options{
+ CustomClient: gsp.context.httpClient,
+ Results: &struct{ Images *[]Image }{&is},
+ MoreHeaders: map[string]string{
+ "X-Auth-Token": gsp.access.AuthToken(),
+ },
+ })
})
return is, err
}
diff --git a/interfaces.go b/interfaces.go
index 6d9b3c7..b73a118 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -9,10 +9,16 @@
// field.
FirstEndpointUrlByCriteria(ApiCriteria) string
- // TODO(sfalvo): get Token() to automatically renew the authentication token if it's near expiry.
-
// AuthToken provides a copy of the current authentication token for the user's credentials.
+ // Note that AuthToken() will not automatically refresh an expired token.
AuthToken() string
+
+ // Revoke allows you to terminate any program's access to the OpenStack API by token ID.
+ Revoke(string) error
+
+ // Reauthenticate attempts to acquire a new authentication token, if the feature is enabled by
+ // AuthOptions.AllowReauth.
+ Reauthenticate() error
}
// CloudServersProvider instances encapsulate a Cloud Servers API, should one exist in the service catalog
diff --git a/reauth.go b/reauth.go
new file mode 100644
index 0000000..5637ea2
--- /dev/null
+++ b/reauth.go
@@ -0,0 +1,23 @@
+package gophercloud
+
+import (
+ "github.com/racker/perigee"
+)
+
+// WithReauth wraps a Perigee request fragment with logic to perform re-authentication
+// if it's deemed necessary.
+//
+// Do not confuse this function with WithReauth()! Although they work together to support reauthentication,
+// WithReauth() actually contains the decision-making logic to determine when to perform a reauth,
+// while WithReauthHandler() is used to configure what a reauth actually entails.
+func (c *Context) WithReauth(ap AccessProvider, f func() error) error {
+ err := f()
+ cause, ok := err.(*perigee.UnexpectedResponseCodeError)
+ if ok && cause.Actual == 401 {
+ err = c.reauthHandler(ap)
+ if err == nil {
+ err = f()
+ }
+ }
+ return err
+}
\ No newline at end of file
diff --git a/reauth_test.go b/reauth_test.go
new file mode 100644
index 0000000..e6ccb17
--- /dev/null
+++ b/reauth_test.go
@@ -0,0 +1,131 @@
+package gophercloud
+
+import (
+ "testing"
+ "github.com/racker/perigee"
+)
+
+// This reauth-handler does nothing, and returns no error.
+func doNothing(_ AccessProvider) error {
+ return nil
+}
+
+func TestOtherErrorsPropegate(t *testing.T) {
+ calls := 0
+ c := TestContext().WithReauthHandler(doNothing)
+
+ err := c.WithReauth(nil, func() error {
+ calls++
+ return &perigee.UnexpectedResponseCodeError{
+ Expected: []int{204},
+ Actual: 404,
+ }
+ })
+
+ if err == nil {
+ t.Error("Expected MyError to be returned; got nil instead.")
+ return
+ }
+ if _, ok := err.(*perigee.UnexpectedResponseCodeError); !ok {
+ t.Error("Expected UnexpectedResponseCodeError; got %#v", err)
+ return
+ }
+ if calls != 1 {
+ t.Errorf("Expected the body to be invoked once; found %d calls instead", calls)
+ return
+ }
+}
+
+func Test401ErrorCausesBodyInvokation2ndTime(t *testing.T) {
+ calls := 0
+ c := TestContext().WithReauthHandler(doNothing)
+
+ err := c.WithReauth(nil, func() error {
+ calls++
+ return &perigee.UnexpectedResponseCodeError{
+ Expected: []int{204},
+ Actual: 401,
+ }
+ })
+
+ if err == nil {
+ t.Error("Expected MyError to be returned; got nil instead.")
+ return
+ }
+ if calls != 2 {
+ t.Errorf("Expected the body to be invoked once; found %d calls instead", calls)
+ return
+ }
+}
+
+func TestReauthAttemptShouldHappen(t *testing.T) {
+ calls := 0
+ c := TestContext().WithReauthHandler(func(_ AccessProvider) error {
+ calls++
+ return nil
+ })
+ c.WithReauth(nil, func() error {
+ return &perigee.UnexpectedResponseCodeError{
+ Expected: []int{204},
+ Actual: 401,
+ }
+ })
+
+ if calls != 1 {
+ t.Errorf("Expected Reauthenticator to be called once; found %d instead", calls)
+ return
+ }
+}
+
+type MyError struct {}
+func (*MyError) Error() string {
+ return "MyError instance"
+}
+
+func TestReauthErrorShouldPropegate(t *testing.T) {
+ c := TestContext().WithReauthHandler(func(_ AccessProvider) error {
+ return &MyError{}
+ })
+
+ err := c.WithReauth(nil, func() error {
+ return &perigee.UnexpectedResponseCodeError{
+ Expected: []int{204},
+ Actual: 401,
+ }
+ })
+
+ if _, ok := err.(*MyError); !ok {
+ t.Errorf("Expected a MyError; got %#v", err)
+ return
+ }
+}
+
+type MyAccess struct {}
+func (my *MyAccess) FirstEndpointUrlByCriteria(ApiCriteria) string {
+ return ""
+}
+func (my *MyAccess) AuthToken() string {
+ return ""
+}
+func (my *MyAccess) Revoke(string) error {
+ return nil
+}
+func (my *MyAccess) Reauthenticate() error {
+ return nil
+}
+
+func TestReauthHandlerUsesSameAccessProvider(t *testing.T) {
+ fakeAccess := &MyAccess{}
+ c := TestContext().WithReauthHandler(func(acc AccessProvider) error {
+ if acc != fakeAccess {
+ t.Errorf("Expected acc = fakeAccess")
+ }
+ return nil
+ })
+ c.WithReauth(fakeAccess, func() error {
+ return &perigee.UnexpectedResponseCodeError{
+ Expected: []int{204},
+ Actual: 401,
+ }
+ })
+}
diff --git a/servers_test.go b/servers_test.go
index 46ba6db..60c71c8 100644
--- a/servers_test.go
+++ b/servers_test.go
@@ -20,6 +20,14 @@
return ""
}
+func (ta *testAccess) Revoke(string) error {
+ return nil
+}
+
+func (ta *testAccess) Reauthenticate() error {
+ return nil
+}
+
func TestGetServersApi(t *testing.T) {
c := TestContext().UseCustomClient(&http.Client{Transport: newTransport().WithResponse("Hello")})
diff --git a/transport_double.go b/transport_double.go
index b65d5db..e764c9a 100644
--- a/transport_double.go
+++ b/transport_double.go
@@ -5,6 +5,8 @@
"io/ioutil"
"net/http"
"strings"
+ "fmt"
+ "testing"
)
type transport struct {
@@ -12,6 +14,7 @@
response string
expectTenantId bool
tenantIdFound bool
+ status int
}
func (t *transport) RoundTrip(req *http.Request) (rsp *http.Response, err error) {
@@ -24,9 +27,17 @@
body := ioutil.NopCloser(strings.NewReader(t.response))
+ if t.status == 0 {
+ t.status = 200
+ }
+ statusMsg := "OK"
+ if (t.status < 200) || (299 < t.status) {
+ statusMsg = "Error"
+ }
+
rsp = &http.Response{
- Status: "200 OK",
- StatusCode: 200,
+ Status: fmt.Sprintf("%d %s", t.status, statusMsg),
+ StatusCode: t.status,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
@@ -72,5 +83,21 @@
func (t *transport) WithResponse(r string) *transport {
t.response = r
+ t.status = 200
return t
}
+
+func (t *transport) WithError(code int) *transport {
+ t.response = fmt.Sprintf("Error %d", code)
+ t.status = code
+ return t
+}
+
+func (t *transport) VerifyCalls(test *testing.T, n int) error {
+ if t.called != n {
+ err := fmt.Errorf("Expected Transport to be called %d times; found %d instead", n, t.called)
+ test.Error(err)
+ return err
+ }
+ return nil
+}
\ No newline at end of file