associationmanager.rb
340 lines
| 12.6 KiB
| text/x-ruby
|
RubyLexer
|
r2376 | require "openid/dh" | ||
require "openid/util" | ||||
require "openid/kvpost" | ||||
require "openid/cryptutil" | ||||
require "openid/protocolerror" | ||||
require "openid/association" | ||||
module OpenID | ||||
class Consumer | ||||
# A superclass for implementing Diffie-Hellman association sessions. | ||||
class DiffieHellmanSession | ||||
class << self | ||||
attr_reader :session_type, :secret_size, :allowed_assoc_types, | ||||
:hashfunc | ||||
end | ||||
def initialize(dh=nil) | ||||
if dh.nil? | ||||
dh = DiffieHellman.from_defaults | ||||
end | ||||
@dh = dh | ||||
end | ||||
# Return the query parameters for requesting an association | ||||
# using this Diffie-Hellman association session | ||||
def get_request | ||||
args = {'dh_consumer_public' => CryptUtil.num_to_base64(@dh.public)} | ||||
if (!@dh.using_default_values?) | ||||
args['dh_modulus'] = CryptUtil.num_to_base64(@dh.modulus) | ||||
args['dh_gen'] = CryptUtil.num_to_base64(@dh.generator) | ||||
end | ||||
return args | ||||
end | ||||
# Process the response from a successful association request and | ||||
# return the shared secret for this association | ||||
def extract_secret(response) | ||||
dh_server_public64 = response.get_arg(OPENID_NS, 'dh_server_public', | ||||
NO_DEFAULT) | ||||
enc_mac_key64 = response.get_arg(OPENID_NS, 'enc_mac_key', NO_DEFAULT) | ||||
dh_server_public = CryptUtil.base64_to_num(dh_server_public64) | ||||
enc_mac_key = Util.from_base64(enc_mac_key64) | ||||
return @dh.xor_secret(self.class.hashfunc, | ||||
dh_server_public, enc_mac_key) | ||||
end | ||||
end | ||||
# A Diffie-Hellman association session that uses SHA1 as its hash | ||||
# function | ||||
class DiffieHellmanSHA1Session < DiffieHellmanSession | ||||
@session_type = 'DH-SHA1' | ||||
@secret_size = 20 | ||||
@allowed_assoc_types = ['HMAC-SHA1'] | ||||
@hashfunc = CryptUtil.method(:sha1) | ||||
end | ||||
# A Diffie-Hellman association session that uses SHA256 as its hash | ||||
# function | ||||
class DiffieHellmanSHA256Session < DiffieHellmanSession | ||||
@session_type = 'DH-SHA256' | ||||
@secret_size = 32 | ||||
@allowed_assoc_types = ['HMAC-SHA256'] | ||||
@hashfunc = CryptUtil.method(:sha256) | ||||
end | ||||
# An association session that does not use encryption | ||||
class NoEncryptionSession | ||||
class << self | ||||
attr_reader :session_type, :allowed_assoc_types | ||||
end | ||||
@session_type = 'no-encryption' | ||||
@allowed_assoc_types = ['HMAC-SHA1', 'HMAC-SHA256'] | ||||
def get_request | ||||
return {} | ||||
end | ||||
def extract_secret(response) | ||||
mac_key64 = response.get_arg(OPENID_NS, 'mac_key', NO_DEFAULT) | ||||
return Util.from_base64(mac_key64) | ||||
end | ||||
end | ||||
# An object that manages creating and storing associations for an | ||||
# OpenID provider endpoint | ||||
class AssociationManager | ||||
def self.create_session(session_type) | ||||
case session_type | ||||
when 'no-encryption' | ||||
NoEncryptionSession.new | ||||
when 'DH-SHA1' | ||||
DiffieHellmanSHA1Session.new | ||||
when 'DH-SHA256' | ||||
DiffieHellmanSHA256Session.new | ||||
else | ||||
raise ArgumentError, "Unknown association session type: "\ | ||||
"#{session_type.inspect}" | ||||
end | ||||
end | ||||
def initialize(store, server_url, compatibility_mode=false, | ||||
negotiator=nil) | ||||
@store = store | ||||
@server_url = server_url | ||||
@compatibility_mode = compatibility_mode | ||||
@negotiator = negotiator || DefaultNegotiator | ||||
end | ||||
def get_association | ||||
if @store.nil? | ||||
return nil | ||||
end | ||||
assoc = @store.get_association(@server_url) | ||||
if assoc.nil? || assoc.expires_in <= 0 | ||||
assoc = negotiate_association | ||||
if !assoc.nil? | ||||
@store.store_association(@server_url, assoc) | ||||
end | ||||
end | ||||
return assoc | ||||
end | ||||
def negotiate_association | ||||
assoc_type, session_type = @negotiator.get_allowed_type | ||||
begin | ||||
return request_association(assoc_type, session_type) | ||||
rescue ServerError => why | ||||
supported_types = extract_supported_association_type(why, assoc_type) | ||||
if !supported_types.nil? | ||||
# Attempt to create an association from the assoc_type and | ||||
# session_type that the server told us it supported. | ||||
assoc_type, session_type = supported_types | ||||
begin | ||||
return request_association(assoc_type, session_type) | ||||
rescue ServerError => why | ||||
Util.log("Server #{@server_url} refused its suggested " \ | ||||
"association type: session_type=#{session_type}, " \ | ||||
"assoc_type=#{assoc_type}") | ||||
return nil | ||||
end | ||||
end | ||||
end | ||||
end | ||||
protected | ||||
def extract_supported_association_type(server_error, assoc_type) | ||||
# Any error message whose code is not 'unsupported-type' should | ||||
# be considered a total failure. | ||||
if (server_error.error_code != 'unsupported-type' or | ||||
server_error.message.is_openid1) | ||||
Util.log("Server error when requesting an association from "\ | ||||
"#{@server_url}: #{server_error.error_text}") | ||||
return nil | ||||
end | ||||
# The server didn't like the association/session type that we | ||||
# sent, and it sent us back a message that might tell us how to | ||||
# handle it. | ||||
Util.log("Unsupported association type #{assoc_type}: "\ | ||||
"#{server_error.error_text}") | ||||
# Extract the session_type and assoc_type from the error message | ||||
assoc_type = server_error.message.get_arg(OPENID_NS, 'assoc_type') | ||||
session_type = server_error.message.get_arg(OPENID_NS, 'session_type') | ||||
if assoc_type.nil? or session_type.nil? | ||||
Util.log("Server #{@server_url} responded with unsupported "\ | ||||
"association session but did not supply a fallback.") | ||||
return nil | ||||
elsif !@negotiator.allowed?(assoc_type, session_type) | ||||
Util.log("Server sent unsupported session/association type: "\ | ||||
"session_type=#{session_type}, assoc_type=#{assoc_type}") | ||||
return nil | ||||
else | ||||
return [assoc_type, session_type] | ||||
end | ||||
end | ||||
# Make and process one association request to this endpoint's OP | ||||
# endpoint URL. Returns an association object or nil if the | ||||
# association processing failed. Raises ServerError when the | ||||
# remote OpenID server returns an error. | ||||
def request_association(assoc_type, session_type) | ||||
assoc_session, args = create_associate_request(assoc_type, session_type) | ||||
begin | ||||
response = OpenID.make_kv_post(args, @server_url) | ||||
return extract_association(response, assoc_session) | ||||
rescue HTTPStatusError => why | ||||
Util.log("Got HTTP status error when requesting association: #{why}") | ||||
return nil | ||||
rescue Message::KeyNotFound => why | ||||
Util.log("Missing required parameter in response from "\ | ||||
"#{@server_url}: #{why}") | ||||
return nil | ||||
rescue ProtocolError => why | ||||
Util.log("Protocol error processing response from #{@server_url}: "\ | ||||
"#{why}") | ||||
return nil | ||||
end | ||||
end | ||||
# Create an association request for the given assoc_type and | ||||
# session_type. Returns a pair of the association session object | ||||
# and the request message that will be sent to the server. | ||||
def create_associate_request(assoc_type, session_type) | ||||
assoc_session = self.class.create_session(session_type) | ||||
args = { | ||||
'mode' => 'associate', | ||||
'assoc_type' => assoc_type, | ||||
} | ||||
if !@compatibility_mode | ||||
args['ns'] = OPENID2_NS | ||||
end | ||||
# Leave out the session type if we're in compatibility mode | ||||
# *and* it's no-encryption. | ||||
if !@compatibility_mode || | ||||
assoc_session.class.session_type != 'no-encryption' | ||||
args['session_type'] = assoc_session.class.session_type | ||||
end | ||||
args.merge!(assoc_session.get_request) | ||||
message = Message.from_openid_args(args) | ||||
return assoc_session, message | ||||
end | ||||
# Given an association response message, extract the OpenID 1.X | ||||
# session type. Returns the association type for this message | ||||
# | ||||
# This function mostly takes care of the 'no-encryption' default | ||||
# behavior in OpenID 1. | ||||
# | ||||
# If the association type is plain-text, this function will | ||||
# return 'no-encryption' | ||||
def get_openid1_session_type(assoc_response) | ||||
# If it's an OpenID 1 message, allow session_type to default | ||||
# to nil (which signifies "no-encryption") | ||||
session_type = assoc_response.get_arg(OPENID1_NS, 'session_type') | ||||
# Handle the differences between no-encryption association | ||||
# respones in OpenID 1 and 2: | ||||
# no-encryption is not really a valid session type for | ||||
# OpenID 1, but we'll accept it anyway, while issuing a | ||||
# warning. | ||||
if session_type == 'no-encryption' | ||||
Util.log("WARNING: #{@server_url} sent 'no-encryption'"\ | ||||
"for OpenID 1.X") | ||||
# Missing or empty session type is the way to flag a | ||||
# 'no-encryption' response. Change the session type to | ||||
# 'no-encryption' so that it can be handled in the same | ||||
# way as OpenID 2 'no-encryption' respones. | ||||
elsif session_type == '' || session_type.nil? | ||||
session_type = 'no-encryption' | ||||
end | ||||
return session_type | ||||
end | ||||
def self.extract_expires_in(message) | ||||
# expires_in should be a base-10 string. | ||||
expires_in_str = message.get_arg(OPENID_NS, 'expires_in', NO_DEFAULT) | ||||
if !(/\A\d+\Z/ =~ expires_in_str) | ||||
raise ProtocolError, "Invalid expires_in field: #{expires_in_str}" | ||||
end | ||||
expires_in_str.to_i | ||||
end | ||||
# Attempt to extract an association from the response, given the | ||||
# association response message and the established association | ||||
# session. | ||||
def extract_association(assoc_response, assoc_session) | ||||
# Extract the common fields from the response, raising an | ||||
# exception if they are not found | ||||
assoc_type = assoc_response.get_arg(OPENID_NS, 'assoc_type', | ||||
NO_DEFAULT) | ||||
assoc_handle = assoc_response.get_arg(OPENID_NS, 'assoc_handle', | ||||
NO_DEFAULT) | ||||
expires_in = self.class.extract_expires_in(assoc_response) | ||||
# OpenID 1 has funny association session behaviour. | ||||
if assoc_response.is_openid1 | ||||
session_type = get_openid1_session_type(assoc_response) | ||||
else | ||||
session_type = assoc_response.get_arg(OPENID2_NS, 'session_type', | ||||
NO_DEFAULT) | ||||
end | ||||
# Session type mismatch | ||||
if assoc_session.class.session_type != session_type | ||||
if (assoc_response.is_openid1 and session_type == 'no-encryption') | ||||
# In OpenID 1, any association request can result in a | ||||
# 'no-encryption' association response. Setting | ||||
# assoc_session to a new no-encryption session should | ||||
# make the rest of this function work properly for | ||||
# that case. | ||||
assoc_session = NoEncryptionSession.new | ||||
else | ||||
# Any other mismatch, regardless of protocol version | ||||
# results in the failure of the association session | ||||
# altogether. | ||||
raise ProtocolError, "Session type mismatch. Expected "\ | ||||
"#{assoc_session.class.session_type}, got "\ | ||||
"#{session_type}" | ||||
end | ||||
end | ||||
# Make sure assoc_type is valid for session_type | ||||
if !assoc_session.class.allowed_assoc_types.member?(assoc_type) | ||||
raise ProtocolError, "Unsupported assoc_type for session "\ | ||||
"#{assoc_session.class.session_type} "\ | ||||
"returned: #{assoc_type}" | ||||
end | ||||
# Delegate to the association session to extract the secret | ||||
# from the response, however is appropriate for that session | ||||
# type. | ||||
begin | ||||
secret = assoc_session.extract_secret(assoc_response) | ||||
rescue Message::KeyNotFound, ArgumentError => why | ||||
raise ProtocolError, "Malformed response for "\ | ||||
"#{assoc_session.class.session_type} "\ | ||||
"session: #{why.message}" | ||||
end | ||||
return Association.from_expires_in(expires_in, assoc_handle, secret, | ||||
assoc_type) | ||||
end | ||||
end | ||||
end | ||||
end | ||||