idres.rb
523 lines
| 18.4 KiB
| text/x-ruby
|
RubyLexer
|
r2376 | require "openid/message" | ||
require "openid/protocolerror" | ||||
require "openid/kvpost" | ||||
require "openid/consumer/discovery" | ||||
require "openid/urinorm" | ||||
module OpenID | ||||
class TypeURIMismatch < ProtocolError | ||||
attr_reader :type_uri, :endpoint | ||||
def initialize(type_uri, endpoint) | ||||
@type_uri = type_uri | ||||
@endpoint = endpoint | ||||
end | ||||
end | ||||
class Consumer | ||||
@openid1_return_to_nonce_name = 'rp_nonce' | ||||
@openid1_return_to_claimed_id_name = 'openid1_claimed_id' | ||||
# Set the name of the query parameter that this library will use | ||||
# to thread a nonce through an OpenID 1 transaction. It will be | ||||
# appended to the return_to URL. | ||||
def self.openid1_return_to_nonce_name=(query_arg_name) | ||||
@openid1_return_to_nonce_name = query_arg_name | ||||
end | ||||
# See openid1_return_to_nonce_name= documentation | ||||
def self.openid1_return_to_nonce_name | ||||
@openid1_return_to_nonce_name | ||||
end | ||||
# Set the name of the query parameter that this library will use | ||||
# to thread the requested URL through an OpenID 1 transaction (for | ||||
# use when verifying discovered information). It will be appended | ||||
# to the return_to URL. | ||||
def self.openid1_return_to_claimed_id_name=(query_arg_name) | ||||
@openid1_return_to_claimed_id_name = query_arg_name | ||||
end | ||||
# See openid1_return_to_claimed_id_name= | ||||
def self.openid1_return_to_claimed_id_name | ||||
@openid1_return_to_claimed_id_name | ||||
end | ||||
# Handles an openid.mode=id_res response. This object is | ||||
# instantiated and used by the Consumer. | ||||
class IdResHandler | ||||
attr_reader :endpoint, :message | ||||
def initialize(message, current_url, store=nil, endpoint=nil) | ||||
@store = store # Fer the nonce and invalidate_handle | ||||
@message = message | ||||
@endpoint = endpoint | ||||
@current_url = current_url | ||||
@signed_list = nil | ||||
# Start the verification process | ||||
id_res | ||||
end | ||||
def signed_fields | ||||
signed_list.map {|x| 'openid.' + x} | ||||
end | ||||
protected | ||||
# This method will raise ProtocolError unless the request is a | ||||
# valid id_res response. Once it has been verified, the methods | ||||
# 'endpoint', 'message', and 'signed_fields' contain the | ||||
# verified information. | ||||
def id_res | ||||
check_for_fields | ||||
verify_return_to | ||||
verify_discovery_results | ||||
check_signature | ||||
check_nonce | ||||
end | ||||
def server_url | ||||
@endpoint.nil? ? nil : @endpoint.server_url | ||||
end | ||||
def openid_namespace | ||||
@message.get_openid_namespace | ||||
end | ||||
def fetch(field, default=NO_DEFAULT) | ||||
@message.get_arg(OPENID_NS, field, default) | ||||
end | ||||
def signed_list | ||||
if @signed_list.nil? | ||||
signed_list_str = fetch('signed', nil) | ||||
if signed_list_str.nil? | ||||
raise ProtocolError, 'Response missing signed list' | ||||
end | ||||
@signed_list = signed_list_str.split(',', -1) | ||||
end | ||||
@signed_list | ||||
end | ||||
def check_for_fields | ||||
# XXX: if a field is missing, we should not have to explicitly | ||||
# check that it's present, just make sure that the fields are | ||||
# actually being used by the rest of the code in | ||||
# tests. Although, which fields are signed does need to be | ||||
# checked somewhere. | ||||
basic_fields = ['return_to', 'assoc_handle', 'sig', 'signed'] | ||||
basic_sig_fields = ['return_to', 'identity'] | ||||
case openid_namespace | ||||
when OPENID2_NS | ||||
require_fields = basic_fields + ['op_endpoint'] | ||||
require_sigs = basic_sig_fields + | ||||
['response_nonce', 'claimed_id', 'assoc_handle',] | ||||
when OPENID1_NS | ||||
require_fields = basic_fields + ['identity'] | ||||
require_sigs = basic_sig_fields | ||||
else | ||||
raise RuntimeError, "check_for_fields doesn't know about "\ | ||||
"namespace #{openid_namespace.inspect}" | ||||
end | ||||
require_fields.each do |field| | ||||
if !@message.has_key?(OPENID_NS, field) | ||||
raise ProtocolError, "Missing required field #{field}" | ||||
end | ||||
end | ||||
require_sigs.each do |field| | ||||
# Field is present and not in signed list | ||||
if @message.has_key?(OPENID_NS, field) && !signed_list.member?(field) | ||||
raise ProtocolError, "#{field.inspect} not signed" | ||||
end | ||||
end | ||||
end | ||||
def verify_return_to | ||||
begin | ||||
msg_return_to = URI.parse(URINorm::urinorm(fetch('return_to'))) | ||||
rescue URI::InvalidURIError | ||||
raise ProtocolError, ("return_to is not a valid URI") | ||||
end | ||||
verify_return_to_args(msg_return_to) | ||||
if !@current_url.nil? | ||||
verify_return_to_base(msg_return_to) | ||||
end | ||||
end | ||||
def verify_return_to_args(msg_return_to) | ||||
return_to_parsed_query = {} | ||||
if !msg_return_to.query.nil? | ||||
CGI.parse(msg_return_to.query).each_pair do |k, vs| | ||||
return_to_parsed_query[k] = vs[0] | ||||
end | ||||
end | ||||
query = @message.to_post_args | ||||
return_to_parsed_query.each_pair do |rt_key, rt_val| | ||||
msg_val = query[rt_key] | ||||
if msg_val.nil? | ||||
raise ProtocolError, "Message missing return_to argument '#{rt_key}'" | ||||
elsif msg_val != rt_val | ||||
raise ProtocolError, ("Parameter '#{rt_key}' value "\ | ||||
"#{msg_val.inspect} does not match "\ | ||||
"return_to's value #{rt_val.inspect}") | ||||
end | ||||
end | ||||
@message.get_args(BARE_NS).each_pair do |bare_key, bare_val| | ||||
rt_val = return_to_parsed_query[bare_key] | ||||
if not return_to_parsed_query.has_key? bare_key | ||||
# This may be caused by your web framework throwing extra | ||||
# entries in to your parameters hash that were not GET or | ||||
# POST parameters. For example, Rails has been known to | ||||
# add "controller" and "action" keys; another server adds | ||||
# at least a "format" key. | ||||
raise ProtocolError, ("Unexpected parameter (not on return_to): "\ | ||||
"'#{bare_key}'=#{rt_val.inspect})") | ||||
end | ||||
if rt_val != bare_val | ||||
raise ProtocolError, ("Parameter '#{bare_key}' value "\ | ||||
"#{bare_val.inspect} does not match "\ | ||||
"return_to's value #{rt_val.inspect}") | ||||
end | ||||
end | ||||
end | ||||
def verify_return_to_base(msg_return_to) | ||||
begin | ||||
app_parsed = URI.parse(URINorm::urinorm(@current_url)) | ||||
rescue URI::InvalidURIError | ||||
raise ProtocolError, "current_url is not a valid URI: #{@current_url}" | ||||
end | ||||
[:scheme, :host, :port, :path].each do |meth| | ||||
if msg_return_to.send(meth) != app_parsed.send(meth) | ||||
raise ProtocolError, "return_to #{meth.to_s} does not match" | ||||
end | ||||
end | ||||
end | ||||
# Raises ProtocolError if the signature is bad | ||||
def check_signature | ||||
if @store.nil? | ||||
assoc = nil | ||||
else | ||||
assoc = @store.get_association(server_url, fetch('assoc_handle')) | ||||
end | ||||
if assoc.nil? | ||||
check_auth | ||||
else | ||||
if assoc.expires_in <= 0 | ||||
# XXX: It might be a good idea sometimes to re-start the | ||||
# authentication with a new association. Doing it | ||||
# automatically opens the possibility for | ||||
# denial-of-service by a server that just returns expired | ||||
# associations (or really short-lived associations) | ||||
raise ProtocolError, "Association with #{server_url} expired" | ||||
elsif !assoc.check_message_signature(@message) | ||||
raise ProtocolError, "Bad signature in response from #{server_url}" | ||||
end | ||||
end | ||||
end | ||||
def check_auth | ||||
Util.log("Using 'check_authentication' with #{server_url}") | ||||
begin | ||||
request = create_check_auth_request | ||||
rescue Message::KeyNotFound => why | ||||
raise ProtocolError, "Could not generate 'check_authentication' "\ | ||||
"request: #{why.message}" | ||||
end | ||||
response = OpenID.make_kv_post(request, server_url) | ||||
process_check_auth_response(response) | ||||
end | ||||
def create_check_auth_request | ||||
signed_list = @message.get_arg(OPENID_NS, 'signed', NO_DEFAULT).split(',') | ||||
# check that we got all the signed arguments | ||||
signed_list.each {|k| | ||||
@message.get_aliased_arg(k, NO_DEFAULT) | ||||
} | ||||
ca_message = @message.copy | ||||
ca_message.set_arg(OPENID_NS, 'mode', 'check_authentication') | ||||
return ca_message | ||||
end | ||||
# Process the response message from a check_authentication | ||||
# request, invalidating associations if requested. | ||||
def process_check_auth_response(response) | ||||
is_valid = response.get_arg(OPENID_NS, 'is_valid', 'false') | ||||
invalidate_handle = response.get_arg(OPENID_NS, 'invalidate_handle') | ||||
if !invalidate_handle.nil? | ||||
Util.log("Received 'invalidate_handle' from server #{server_url}") | ||||
if @store.nil? | ||||
Util.log('Unexpectedly got "invalidate_handle" without a store!') | ||||
else | ||||
@store.remove_association(server_url, invalidate_handle) | ||||
end | ||||
end | ||||
if is_valid != 'true' | ||||
raise ProtocolError, ("Server #{server_url} responds that the "\ | ||||
"'check_authentication' call is not valid") | ||||
end | ||||
end | ||||
def check_nonce | ||||
case openid_namespace | ||||
when OPENID1_NS | ||||
nonce = | ||||
@message.get_arg(BARE_NS, Consumer.openid1_return_to_nonce_name) | ||||
# We generated the nonce, so it uses the empty string as the | ||||
# server URL | ||||
server_url = '' | ||||
when OPENID2_NS | ||||
nonce = @message.get_arg(OPENID2_NS, 'response_nonce') | ||||
server_url = self.server_url | ||||
else | ||||
raise StandardError, 'Not reached' | ||||
end | ||||
if nonce.nil? | ||||
raise ProtocolError, 'Nonce missing from response' | ||||
end | ||||
begin | ||||
time, extra = Nonce.split_nonce(nonce) | ||||
rescue ArgumentError => why | ||||
raise ProtocolError, "Malformed nonce: #{nonce.inspect}" | ||||
end | ||||
if !@store.nil? && !@store.use_nonce(server_url, time, extra) | ||||
raise ProtocolError, ("Nonce already used or out of range: "\ | ||||
"#{nonce.inspect}") | ||||
end | ||||
end | ||||
def verify_discovery_results | ||||
begin | ||||
case openid_namespace | ||||
when OPENID1_NS | ||||
verify_discovery_results_openid1 | ||||
when OPENID2_NS | ||||
verify_discovery_results_openid2 | ||||
else | ||||
raise StandardError, "Not reached: #{openid_namespace}" | ||||
end | ||||
rescue Message::KeyNotFound => why | ||||
raise ProtocolError, "Missing required field: #{why.message}" | ||||
end | ||||
end | ||||
def verify_discovery_results_openid2 | ||||
to_match = OpenIDServiceEndpoint.new | ||||
to_match.type_uris = [OPENID_2_0_TYPE] | ||||
to_match.claimed_id = fetch('claimed_id', nil) | ||||
to_match.local_id = fetch('identity', nil) | ||||
to_match.server_url = fetch('op_endpoint') | ||||
if to_match.claimed_id.nil? && !to_match.local_id.nil? | ||||
raise ProtocolError, ('openid.identity is present without '\ | ||||
'openid.claimed_id') | ||||
elsif !to_match.claimed_id.nil? && to_match.local_id.nil? | ||||
raise ProtocolError, ('openid.claimed_id is present without '\ | ||||
'openid.identity') | ||||
# This is a response without identifiers, so there's really no | ||||
# checking that we can do, so return an endpoint that's for | ||||
# the specified `openid.op_endpoint' | ||||
elsif to_match.claimed_id.nil? | ||||
@endpoint = | ||||
OpenIDServiceEndpoint.from_op_endpoint_url(to_match.server_url) | ||||
return | ||||
end | ||||
if @endpoint.nil? | ||||
Util.log('No pre-discovered information supplied') | ||||
discover_and_verify(to_match.claimed_id, [to_match]) | ||||
else | ||||
begin | ||||
verify_discovery_single(@endpoint, to_match) | ||||
rescue ProtocolError => why | ||||
Util.log("Error attempting to use stored discovery "\ | ||||
"information: #{why.message}") | ||||
Util.log("Attempting discovery to verify endpoint") | ||||
discover_and_verify(to_match.claimed_id, [to_match]) | ||||
end | ||||
end | ||||
if @endpoint.claimed_id != to_match.claimed_id | ||||
@endpoint = @endpoint.dup | ||||
@endpoint.claimed_id = to_match.claimed_id | ||||
end | ||||
end | ||||
def verify_discovery_results_openid1 | ||||
claimed_id = | ||||
@message.get_arg(BARE_NS, Consumer.openid1_return_to_claimed_id_name) | ||||
if claimed_id.nil? | ||||
if @endpoint.nil? | ||||
raise ProtocolError, ("When using OpenID 1, the claimed ID must "\ | ||||
"be supplied, either by passing it through "\ | ||||
"as a return_to parameter or by using a "\ | ||||
"session, and supplied to the IdResHandler "\ | ||||
"when it is constructed.") | ||||
else | ||||
claimed_id = @endpoint.claimed_id | ||||
end | ||||
end | ||||
to_match = OpenIDServiceEndpoint.new | ||||
to_match.type_uris = [OPENID_1_1_TYPE] | ||||
to_match.local_id = fetch('identity') | ||||
# Restore delegate information from the initiation phase | ||||
to_match.claimed_id = claimed_id | ||||
to_match_1_0 = to_match.dup | ||||
to_match_1_0.type_uris = [OPENID_1_0_TYPE] | ||||
if !@endpoint.nil? | ||||
begin | ||||
begin | ||||
verify_discovery_single(@endpoint, to_match) | ||||
rescue TypeURIMismatch | ||||
verify_discovery_single(@endpoint, to_match_1_0) | ||||
end | ||||
rescue ProtocolError => why | ||||
Util.log('Error attempting to use stored discovery information: ' + | ||||
why.message) | ||||
Util.log('Attempting discovery to verify endpoint') | ||||
else | ||||
return @endpoint | ||||
end | ||||
end | ||||
# Either no endpoint was supplied or OpenID 1.x verification | ||||
# of the information that's in the message failed on that | ||||
# endpoint. | ||||
discover_and_verify(to_match.claimed_id, [to_match, to_match_1_0]) | ||||
end | ||||
# Given an endpoint object created from the information in an | ||||
# OpenID response, perform discovery and verify the discovery | ||||
# results, returning the matching endpoint that is the result of | ||||
# doing that discovery. | ||||
def discover_and_verify(claimed_id, to_match_endpoints) | ||||
Util.log("Performing discovery on #{claimed_id}") | ||||
_, services = OpenID.discover(claimed_id) | ||||
if services.length == 0 | ||||
# XXX: this might want to be something other than | ||||
# ProtocolError. In Python, it's DiscoveryFailure | ||||
raise ProtocolError, ("No OpenID information found at "\ | ||||
"#{claimed_id}") | ||||
end | ||||
verify_discovered_services(claimed_id, services, to_match_endpoints) | ||||
end | ||||
def verify_discovered_services(claimed_id, services, to_match_endpoints) | ||||
# Search the services resulting from discovery to find one | ||||
# that matches the information from the assertion | ||||
failure_messages = [] | ||||
for endpoint in services | ||||
for to_match_endpoint in to_match_endpoints | ||||
begin | ||||
verify_discovery_single(endpoint, to_match_endpoint) | ||||
rescue ProtocolError => why | ||||
failure_messages << why.message | ||||
else | ||||
# It matches, so discover verification has | ||||
# succeeded. Return this endpoint. | ||||
@endpoint = endpoint | ||||
return | ||||
end | ||||
end | ||||
end | ||||
Util.log("Discovery verification failure for #{claimed_id}") | ||||
failure_messages.each do |failure_message| | ||||
Util.log(" * Endpoint mismatch: " + failure_message) | ||||
end | ||||
# XXX: is DiscoveryFailure in Python OpenID | ||||
raise ProtocolError, ("No matching endpoint found after "\ | ||||
"discovering #{claimed_id}") | ||||
end | ||||
def verify_discovery_single(endpoint, to_match) | ||||
# Every type URI that's in the to_match endpoint has to be | ||||
# present in the discovered endpoint. | ||||
for type_uri in to_match.type_uris | ||||
if !endpoint.uses_extension(type_uri) | ||||
raise TypeURIMismatch.new(type_uri, endpoint) | ||||
end | ||||
end | ||||
# Fragments do not influence discovery, so we can't compare a | ||||
# claimed identifier with a fragment to discovered information. | ||||
defragged_claimed_id = | ||||
case Yadis::XRI.identifier_scheme(endpoint.claimed_id) | ||||
when :xri | ||||
endpoint.claimed_id | ||||
when :uri | ||||
begin | ||||
parsed = URI.parse(endpoint.claimed_id) | ||||
rescue URI::InvalidURIError | ||||
endpoint.claimed_id | ||||
else | ||||
parsed.fragment = nil | ||||
parsed.to_s | ||||
end | ||||
else | ||||
raise StandardError, 'Not reached' | ||||
end | ||||
if defragged_claimed_id != endpoint.claimed_id | ||||
raise ProtocolError, ("Claimed ID does not match (different "\ | ||||
"subjects!), Expected "\ | ||||
"#{defragged_claimed_id}, got "\ | ||||
"#{endpoint.claimed_id}") | ||||
end | ||||
if to_match.get_local_id != endpoint.get_local_id | ||||
raise ProtocolError, ("local_id mismatch. Expected "\ | ||||
"#{to_match.get_local_id}, got "\ | ||||
"#{endpoint.get_local_id}") | ||||
end | ||||
# If the server URL is nil, this must be an OpenID 1 | ||||
# response, because op_endpoint is a required parameter in | ||||
# OpenID 2. In that case, we don't actually care what the | ||||
# discovered server_url is, because signature checking or | ||||
# check_auth should take care of that check for us. | ||||
if to_match.server_url.nil? | ||||
if to_match.preferred_namespace != OPENID1_NS | ||||
raise StandardError, | ||||
"The code calling this must ensure that OpenID 2 "\ | ||||
"responses have a non-none `openid.op_endpoint' and "\ | ||||
"that it is set as the `server_url' attribute of the "\ | ||||
"`to_match' endpoint." | ||||
end | ||||
elsif to_match.server_url != endpoint.server_url | ||||
raise ProtocolError, ("OP Endpoint mismatch. Expected"\ | ||||
"#{to_match.server_url}, got "\ | ||||
"#{endpoint.server_url}") | ||||
end | ||||
end | ||||
end | ||||
end | ||||
end | ||||