1
0
Fork 0
mirror of https://github.com/fog/fog.git synced 2022-11-09 13:51:43 -05:00

Merge pull request #1320 from brightbox/refresh_tokens

[Brightbox] Refresh tokens
This commit is contained in:
Paul Thornthwaite 2012-11-29 08:31:22 -08:00
commit c6518ee3b1
4 changed files with 555 additions and 104 deletions

View file

@ -1,5 +1,6 @@
require 'fog/brightbox'
require 'fog/compute'
require 'fog/brightbox/oauth2'
module Fog
module Compute
@ -16,6 +17,12 @@ module Fog
# User credentials (still requires client details)
recognizes :brightbox_username, :brightbox_password, :brightbox_account
# Cached tokens
recognizes :brightbox_access_token, :brightbox_refresh_token
# Automatic token management
recognizes :brightbox_token_management
# Excon connection settings
recognizes :persistent
@ -127,6 +134,139 @@ module Fog
request :update_user
module Shared
include Fog::Brightbox::OAuth2
# Creates a new instance of the Brightbox Compute service
#
# @note If you open a connection using just a refresh token when it
# expires the service will no longer be able to authenticate.
#
# @param [Hash] options
# @option options [String] :brightbox_api_url Override the default (or configured) API endpoint
# @option options [String] :brightbox_auth_url Override the default (or configured) API authentication endpoint
# @option options [String] :brightbox_client_id Client identifier to authenticate with (overrides configured)
# @option options [String] :brightbox_secret Client secret to authenticate with (overrides configured)
# @option options [String] :brightbox_username Email or user identifier for user based authentication
# @option options [String] :brightbox_password Password for user based authentication
# @option options [String] :brightbox_account Account identifier to scope this connection to
# @option options [String] :connection_options Settings to pass to underlying {Fog::Connection}
# @option options [Boolean] :persistent Sets a persistent HTTP {Fog::Connection}
# @option options [String] :brightbox_access_token Sets the OAuth access token to use rather than requesting a new token
# @option options [String] :brightbox_refresh_token Sets the refresh token to use when requesting a newer access token
# @option options [String] :brightbox_token_management Overide the existing behaviour to request access tokens if expired (default is `true`)
#
def initialize(options)
# Currently authentication and api endpoints are the same but may change
@auth_url = options[:brightbox_auth_url] || Fog.credentials[:brightbox_auth_url] || API_URL
@auth_connection = Fog::Connection.new(@auth_url)
@api_url = options[:brightbox_api_url] || Fog.credentials[:brightbox_api_url] || API_URL
@connection_options = options[:connection_options] || {}
@persistent = options[:persistent] || false
@connection = Fog::Connection.new(@api_url, @persistent, @connection_options)
# Authentication options
client_id = options[:brightbox_client_id] || Fog.credentials[:brightbox_client_id]
client_secret = options[:brightbox_secret] || Fog.credentials[:brightbox_secret]
username = options[:brightbox_username] || Fog.credentials[:brightbox_username]
password = options[:brightbox_password] || Fog.credentials[:brightbox_password]
@configured_account = options[:brightbox_account] || Fog.credentials[:brightbox_account]
# Request account can be changed at anytime and changes behaviour of future requests
@scoped_account = @configured_account
credential_options = {:username => username, :password => password}
@credentials = CredentialSet.new(client_id, client_secret, credential_options)
# If existing tokens have been cached, allow continued use of them in the service
@credentials.update_tokens(options[:brightbox_access_token], options[:brightbox_refresh_token])
@token_management = options.fetch(:brightbox_token_management, true)
end
# Sets the scoped account for future requests
# @param [String] scoped_account Identifier of the account to scope request to
def scoped_account=(scoped_account)
@scoped_account = scoped_account
end
# This returns the account identifier that the request should be scoped by
# based on the options passed to the request and current configuration
#
# @param [String] options_account Any identifier passed into the request
#
# @return [String, nil] The account identifier to scope the request to or nil
def scoped_account(options_account = nil)
[options_account, @scoped_account].compact.first
end
# Resets the scoped account back to intially configured one
def scoped_account_reset
@scoped_account = @configured_account
end
# Returns the scoped account being used for requests
#
# * For API clients this is the owning account
# * For User applications this is the account specified by either +account_id+
# option on a connection or the +brightbox_account+ setting in your configuration
#
# @return [Fog::Compute::Brightbox::Account]
#
def account
Fog::Compute::Brightbox::Account.new(get_scoped_account).tap do |acc|
# Connection is more like the compute 'service'
acc.connection = self
end
end
# Returns true if authentication is being performed as a user
# @return [Boolean]
def authenticating_as_user?
@credentials.user_details?
end
# Returns true if an access token is set
# @return [Boolean]
def access_token_available?
!! @credentials.access_token
end
# Returns the current access token or nil
# @return [String,nil]
def access_token
@credentials.access_token
end
# Returns the current refresh token or nil
# @return [String,nil]
def refresh_token
@credentials.refresh_token
end
# Requests a new access token
#
# @return [String] New access token
def get_access_token
begin
get_access_token!
rescue Excon::Errors::Unauthorized, Excon::Errors::BadRequest
@credentials.update_tokens(nil, nil)
end
@credentials.access_token
end
# Requests a new access token and raises if there is a problem
#
# @return [String] New access token
# @raise [Excon::Errors::BadRequest] The credentials are expired or incorrect
#
def get_access_token!
response = request_access_token(@auth_connection, @credentials)
update_credentials_from_response(@credentials, response)
@credentials.access_token
end
# Returns an identifier for the default image for use
#
# Currently tries to find the latest version Ubuntu LTS (i686) widening
@ -140,59 +280,91 @@ module Fog
return @default_image_id unless @default_image_id.nil?
@default_image_id = Fog.credentials[:brightbox_default_image] || select_default_image
end
private
# This makes a request of the API based on the configured setting for
# token management.
#
# @param [Hash] options Excon compatible options
# @see https://github.com/geemus/excon/blob/master/lib/excon/connection.rb
#
# @return [Hash] Data of response body
#
def make_request(options)
if @token_management
managed_token_request(options)
else
authenticated_request(options)
end
end
# This request checks for access tokens and will ask for a new one if
# it receives Unauthorized from the API before repeating the request
#
# @param [Hash] options Excon compatible options
#
# @return [Excon::Response]
def managed_token_request(options)
begin
get_access_token unless access_token_available?
response = authenticated_request(options)
rescue Excon::Errors::Unauthorized
get_access_token
response = authenticated_request(options)
end
end
# This request makes an authenticated request of the API using currently
# setup credentials.
#
# @param [Hash] options Excon compatible options
#
# @return [Excon::Response]
def authenticated_request(options)
headers = options[:headers] || {}
headers.merge!("Authorization" => "OAuth #{@credentials.access_token}", "Content-Type" => "application/json")
options[:headers] = headers
# TODO This is just a wrapper around a call to Excon::Connection#request
# so can be extracted from Compute by passing in the connection,
# credentials and options
@connection.request(options)
end
end
# The Mock Service allows you to run a fake instance of the Service
# which makes no real connections.
#
# @todo Implement
#
class Mock
include Shared
def initialize(options)
@brightbox_client_id = options[:brightbox_client_id] || Fog.credentials[:brightbox_client_id]
@brightbox_secret = options[:brightbox_secret] || Fog.credentials[:brightbox_secret]
def request(method, path, expected_responses, parameters = {})
_request
end
def request(options)
raise "Not implemented"
def request_access_token(connection, credentials)
_request
end
private
def _request
raise Fog::Errors::MockNotImplemented
end
def select_default_image
"img-mockd"
end
end
# The Real Service actually makes real connections to the Brightbox
# service.
#
class Real
include Shared
# Creates a new instance of the Brightbox Compute service
#
# @param [Hash] options
# @option options [String] :brightbox_api_url Override the default (or configured) API endpoint
# @option options [String] :brightbox_auth_url Override the default (or configured) API authentication endpoint
# @option options [String] :brightbox_client_id Client identifier to authenticate with (overrides configured)
# @option options [String] :brightbox_secret Client secret to authenticate with (overrides configured)
# @option options [String] :brightbox_username Email or user identifier for user based authentication
# @option options [String] :brightbox_password Password for user based authentication
# @option options [String] :brightbox_account Account identifier to scope this connection to
# @option options [String] :connection_options Settings to pass to underlying {Fog::Connection}
# @option options [Boolean] :persistent Sets a persistent HTTP {Fog::Connection}
#
def initialize(options)
# Currently authentication and api endpoints are the same but may change
@auth_url = options[:brightbox_auth_url] || Fog.credentials[:brightbox_auth_url] || API_URL
@api_url = options[:brightbox_api_url] || Fog.credentials[:brightbox_api_url] || API_URL
@connection_options = options[:connection_options] || {}
@persistent = options[:persistent] || false
@connection = Fog::Connection.new(@api_url, @persistent, @connection_options)
# Authentication options
@brightbox_client_id = options[:brightbox_client_id] || Fog.credentials[:brightbox_client_id]
@brightbox_secret = options[:brightbox_secret] || Fog.credentials[:brightbox_secret]
@brightbox_username = options[:brightbox_username] || Fog.credentials[:brightbox_username]
@brightbox_password = options[:brightbox_password] || Fog.credentials[:brightbox_password]
@brightbox_account = options[:brightbox_account] || Fog.credentials[:brightbox_account]
end
# Makes an API request to the given path using passed options or those
# set with the service setup
#
@ -214,81 +386,27 @@ module Fog
:path => path,
:expects => expected_responses
}
parameters[:account_id] = @brightbox_account if parameters[:account_id].nil? && @brightbox_account
# Select the account to scope for this request
account = scoped_account(parameters.fetch(:account_id, nil))
if account
request_options[:query] = { :account_id => account }
end
request_options[:body] = Fog::JSON.encode(parameters) unless parameters.empty?
make_request(request_options)
end
# Returns the scoped account being used for requests
#
# * For API clients this is the owning account
# * For User applications this is the account specified by either +account_id+
# option on a connection or the +brightbox_account+ setting in your configuration
#
# @return [Fog::Compute::Brightbox::Account]
#
def account
Fog::Compute::Brightbox::Account.new(get_scoped_account).tap do |acc|
# Connection is more like the compute 'service'
acc.connection = self
end
end
response = make_request(request_options)
# Returns true if authentication is being performed as a user
# @return [Boolean]
def authenticating_as_user?
@brightbox_username && @brightbox_password
end
private
def get_oauth_token(options = {})
auth_url = options[:brightbox_auth_url] || @auth_url
connection = Fog::Connection.new(auth_url)
authentication_body_hash = if authenticating_as_user?
{
'client_id' => @brightbox_client_id,
'grant_type' => 'password',
'username' => @brightbox_username,
'password' => @brightbox_password
}
else
{'client_id' => @brightbox_client_id, 'grant_type' => 'none'}
end
@authentication_body = Fog::JSON.encode(authentication_body_hash)
response = connection.request({
:path => "/token",
:expects => 200,
:headers => {
'Authorization' => "Basic " + Base64.encode64("#{@brightbox_client_id}:#{@brightbox_secret}").chomp,
'Content-Type' => 'application/json'
},
:method => 'POST',
:body => @authentication_body
})
@oauth_token = Fog::JSON.decode(response.body)["access_token"]
return @oauth_token
end
def make_request(params)
begin
get_oauth_token if @oauth_token.nil?
response = authenticated_request(params)
rescue Excon::Errors::Unauthorized
get_oauth_token
response = authenticated_request(params)
end
# FIXME We should revert to returning the Excon::Request after a suitable
# configuration option is in place to switch back to this incorrect behaviour
unless response.body.empty?
response = Fog::JSON.decode(response.body)
Fog::JSON.decode(response.body)
else
response
end
end
def authenticated_request(options)
headers = options[:headers] || {}
headers.merge!("Authorization" => "OAuth #{@oauth_token}", "Content-Type" => "application/json")
options[:headers] = headers
@connection.request(options)
end
private
# Queries the API and tries to select the most suitable official Image
# to use if the user chooses not to select their own.

164
lib/fog/brightbox/oauth2.rb Normal file
View file

@ -0,0 +1,164 @@
# This module covers Brightbox's partial implementation of OAuth 2.0
# and enables fog clients to implement several authentictication strategies
#
# @see http://tools.ietf.org/html/draft-ietf-oauth-v2-10
#
module Fog::Brightbox::OAuth2
# This builds the simplest form of requesting an access token
# based on the arguments passed in
#
# @param [Fog::Connection] connection
# @param [CredentialSet] credentials
#
# @return [Excon::Response]
def request_access_token(connection, credentials)
token_strategy = credentials.best_grant_strategy
header_content = "#{credentials.client_id}:#{credentials.client_secret}"
encoded_credentials = Base64.encode64(header_content).chomp
connection.request({
:path => "/token",
:expects => 200,
:headers => {
'Authorization' => "Basic #{encoded_credentials}",
'Content-Type' => 'application/json'
},
:method => 'POST',
:body => Fog::JSON.encode(token_strategy.authorization_body_data)
})
end
# Encapsulates credentials required to request access tokens from the
# Brightbox authorisation servers
#
# @todo Interface to update certain credentials (after password change)
#
class CredentialSet
attr_reader :client_id, :client_secret, :username, :password
attr_reader :access_token, :refresh_token
#
# @param [String] client_id
# @param [String] client_secret
# @param [Hash] options
# @option options [String] :username
# @option options [String] :password
#
def initialize(client_id, client_secret, options = {})
@client_id = client_id
@client_secret = client_secret
@username = options[:username]
@password = options[:password]
@access_token = options[:access_token]
@refresh_token = options[:refresh_token]
end
# Returns true if user details are available
# @return [Boolean]
def user_details?
!!(@username && @password)
end
# Is an access token available for these credentials?
def access_token?
!!@access_token
end
# Is a refresh token available for these credentials?
def refresh_token?
!!@refresh_token
end
# Updates the credentials with newer tokens
def update_tokens(access_token, refresh_token = nil)
@access_token = access_token
@refresh_token = refresh_token
end
# Based on available credentials returns the best strategy
#
# @todo Add a means to dictate which should or shouldn't be used
#
def best_grant_strategy
if refresh_token?
RefreshTokenStrategy.new(self)
elsif user_details?
UserCredentialsStrategy.new(self)
else
ClientCredentialsStrategy.new(self)
end
end
end
# This strategy class is the basis for OAuth2 grant types
#
# @abstract Need to implement {#authorization_body_data} to return a
# Hash matching the expected parameter form for the OAuth request
#
# @todo Strategies should be able to validate if credentials are suitable
# so just client credentials cannot be used with user strategies
#
class GrantTypeStrategy
def initialize(credentials)
@credentials = credentials
end
def authorization_body_data
raise "Not implemented"
end
end
# This implements client based authentication/authorization
# based on the existing trust relationship using the `none`
# grant type.
#
class ClientCredentialsStrategy < GrantTypeStrategy
def authorization_body_data
{
"grant_type" => "none",
"client_id" => @credentials.client_id
}
end
end
# This passes user details through so the returned token
# carries the privileges of the user not account limited
# by the client
#
class UserCredentialsStrategy < GrantTypeStrategy
def authorization_body_data
{
"grant_type" => "password",
"client_id" => @credentials.client_id,
"username" => @credentials.username,
"password" => @credentials.password
}
end
end
# This strategy attempts to use a refresh_token gained during an earlier
# request to reuse the credentials given originally
#
class RefreshTokenStrategy < GrantTypeStrategy
def authorization_body_data
{
"grant_type" => "refresh_token",
"client_id" => @credentials.client_id,
"refresh_token" => @credentials.refresh_token
}
end
end
private
# This updates the current credentials if passed a valid response
#
# @param [CredentialSet] credentials Credentials to update
# @param [Excon::Response] response Response object to parse value from
#
def update_credentials_from_response(credentials, response)
response_data = Fog::JSON.decode(response.body)
credentials.update_tokens(response_data["access_token"], response_data["refresh_token"])
end
end

View file

@ -18,7 +18,10 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do
:brightbox_secret => "12345abdef6789",
:brightbox_username => "user-12345",
:brightbox_password => "password1234",
:brightbox_account => "acc-12345"
:brightbox_account => "acc-12345",
:brightbox_access_token => "12345abdef6789",
:brightbox_refresh_token => "12345abdef6789",
:brightbox_token_management => false
}.each_pair do |option, sample|
tests("recognises :#{option}").returns(true) do
options = {:provider => "Brightbox"}
@ -32,4 +35,67 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do
end
end
end
tests("automatic token management") do
service_options = {:provider => "Brightbox"}
tests("when enabled (default)") do
service_options[:brightbox_token_management] = true
tests("using bad token") do
service_options[:brightbox_access_token] = "bad-token"
tests("#request").returns(true, "returns a Hash") do
pending if Fog.mocking?
service = Fog::Compute.new(service_options)
response = service.get_authenticated_user
response.is_a?(Hash) # This is an outstanding issue, should be Excon::Response
end
end
end
tests("when disabled") do
service_options[:brightbox_token_management] = false
tests("using bad token") do
service_options[:brightbox_access_token] = "bad-token"
tests("#request").raises(Excon::Errors::Unauthorized) do
pending if Fog.mocking?
service = Fog::Compute.new(service_options)
service.get_authenticated_user
end
end
end
end
tests("account scoping") do
service = Fog::Compute.new(:provider => "Brightbox")
configured_account = Fog.credentials[:brightbox_account]
tests("when Fog.credentials are #{configured_account}") do
test("#scoped_account == #{configured_account}") { service.scoped_account == configured_account }
end
set_account = "acc-35791"
tests("when Compute instance is updated to #{set_account}") do
service.scoped_account = set_account
test("#scoped_account == #{set_account}") { service.scoped_account == set_account }
end
tests("when Compute instance is reset") do
service.scoped_account_reset
test("#scoped_account == #{configured_account}") { service.scoped_account == configured_account }
end
optioned_account = "acc-56789"
tests("when Compute instance created with :brightbox_account => #{optioned_account}") do
service = Fog::Compute.new(:provider => "Brightbox", :brightbox_account => optioned_account )
test("#scoped_account == #{optioned_account}") { service.scoped_account == optioned_account }
end
request_account = "acc-24680"
tests("when requested with #{request_account}") do
test("#scoped_account(#{request_account}) == #{request_account}") { service.scoped_account(request_account) == request_account }
end
end
end

View file

@ -0,0 +1,103 @@
Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do
tests("CredentialSet") do
@client_id = "app-12345"
@client_secret = "__mashed_keys_123__"
@username = "usr-12345"
@password = "__mushed_keys_321__"
@access_token = "12efde32fdfe4989"
@refresh_token = "7894389f9074f071"
tests("with client credentials") do
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret)
tests("#user_details?").returns(false) { credentials.user_details? }
tests("#access_token?").returns(false) { credentials.access_token? }
tests("#refresh_token?").returns(false) { credentials.refresh_token? }
tests("#best_grant_strategy").returns(true) do
credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::ClientCredentialsStrategy)
end
end
tests("with user credentials") do
options = {:username => @username, :password => @password}
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options)
tests("#user_details?").returns(true) { credentials.user_details? }
tests("#access_token?").returns(false) { credentials.access_token? }
tests("#refresh_token?").returns(false) { credentials.refresh_token? }
tests("#best_grant_strategy").returns(true) do
credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::UserCredentialsStrategy)
end
end
tests("with existing tokens") do
options = {:username => @username, :access_token => @access_token, :refresh_token => @refresh_token}
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options)
tests("#user_details?").returns(false) { credentials.user_details? }
tests("#access_token?").returns(true) { credentials.access_token? }
tests("#refresh_token?").returns(true) { credentials.refresh_token? }
tests("#best_grant_strategy").returns(true) do
credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::RefreshTokenStrategy)
end
end
end
tests("GrantTypeStrategy") do
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret)
strategy = Fog::Brightbox::OAuth2::GrantTypeStrategy.new(credentials)
tests("#respond_to? :authorization_body_data").returns(true) do
strategy.respond_to?(:authorization_body_data)
end
end
tests("ClientCredentialsStrategy") do
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret)
strategy = Fog::Brightbox::OAuth2::ClientCredentialsStrategy.new(credentials)
tests("#respond_to? :authorization_body_data").returns(true) do
strategy.respond_to?(:authorization_body_data)
end
tests("#authorization_body_data") do
authorization_body_data = strategy.authorization_body_data
test("grant_type == none") { authorization_body_data["grant_type"] == "none" }
test("client_id == #{@client_id}") { authorization_body_data["client_id"] == @client_id }
end
end
tests("UserCredentialsStrategy") do
options = {:username => @username, :password => @password}
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options)
strategy = Fog::Brightbox::OAuth2::UserCredentialsStrategy.new(credentials)
tests("#respond_to? :authorization_body_data").returns(true) do
strategy.respond_to?(:authorization_body_data)
end
tests("#authorization_body_data") do
authorization_body_data = strategy.authorization_body_data
test("grant_type == password") { authorization_body_data["grant_type"] == "password" }
test("client_id == #{@client_id}") { authorization_body_data["client_id"] == @client_id }
test("username == #{@username}") { authorization_body_data["username"] == @username }
test("password == #{@password}") { authorization_body_data["password"] == @password }
end
end
tests("RefreshTokenStrategy") do
refresh_token = "ab4b39dddf909"
options = {:refresh_token => refresh_token}
credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options)
strategy = Fog::Brightbox::OAuth2::RefreshTokenStrategy.new(credentials)
tests("#respond_to? :authorization_body_data").returns(true) do
strategy.respond_to?(:authorization_body_data)
end
tests("#authorization_body_data") do
authorization_body_data = strategy.authorization_body_data
test("grant_type == refresh_token") { authorization_body_data["grant_type"] == "refresh_token" }
test("client_id == #{@client_id}") { authorization_body_data["client_id"] == @client_id }
test("refresh_token == #{refresh_token}") { authorization_body_data["refresh_token"] == refresh_token }
end
end
end