adds SAML strategy

This commit is contained in:
raecoo 2011-08-29 16:55:48 +08:00
parent abe1d1c9aa
commit 3079ffcdae
8 changed files with 401 additions and 3 deletions

View File

@ -66,7 +66,40 @@ are not familiar with these authentication methods, please just avoid them.
Direct users to '/auth/ldap' to have them authenticated via your
company's LDAP server.
== SAML
Use the SAML strategy as a middleware in your application:
require 'omniauth/enterprise'
use OmniAuth::Strategies::SAML,
:assertion_consumer_service_url => "consumer_service_url",
:issuer => "issuer",
:idp_sso_target_url => "idp_sso_target_url",
:idp_cert_fingerprint => "E7:91:B2:E1:...",
:name_identifier_format => "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
:assertion_consumer_service_url
The URL at which the SAML assertion should be received.
:issuer
The name of your application. Some identity providers might need this to establish the
identity of the service provider requesting the login.
:idp_sso_target_url
The URL to which the authentication request should be sent. This would be on the identity provider.
:idp_cert_fingerprint
The certificate fingerprint, e.g. "90:CC:16:F0:8D:A6:D1:C6:BB:27:2D:BA:93:80:1A:1F:16:8E:4E:08".
This is provided from the identity provider when setting up the relationship.
:name_identifier_format
Describes the format of the username required by this application.
If you need the email address, use "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress".
See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 8.3 for
other options. Note that the identity provider might not support all options.
== Multiple Strategies
If you're using multiple strategies together, use OmniAuth's Builder. That's

View File

@ -4,5 +4,6 @@ module OmniAuth
module Strategies
autoload :CAS, 'omniauth/strategies/cas'
autoload :LDAP, 'omniauth/strategies/ldap'
autoload :SAML, 'omniauth/strategies/saml'
end
end

View File

@ -0,0 +1,50 @@
require 'omniauth/enterprise'
module OmniAuth
module Strategies
class SAML
include OmniAuth::Strategy
autoload :AuthRequest, 'omniauth/strategies/saml/auth_request'
autoload :AuthResponse, 'omniauth/strategies/saml/auth_response'
autoload :ValidationError, 'omniauth/strategies/saml/validation_error'
autoload :XMLSecurity, 'omniauth/strategies/saml/xml_security'
@@settings = {}
def initialize(app, options={})
super(app, :saml)
@@settings = {
:assertion_consumer_service_url => options[:assertion_consumer_service_url],
:issuer => options[:issuer],
:idp_sso_target_url => options[:idp_sso_target_url],
:idp_cert_fingerprint => options[:idp_cert_fingerprint],
:name_identifier_format => options[:name_identifier_format] || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
}
end
def request_phase
request = OmniAuth::Strategies::SAML::AuthRequest.new
redirect(request.create(@@settings))
end
def callback_phase
begin
response = OmniAuth::Strategies::SAML::AuthResponse.new(request.params['SAMLResponse'])
response.settings = @@settings
@name_id = response.name_id
return fail!(:invalid_ticket, 'Invalid SAML Ticket') if @name_id.nil? || @name_id.empty?
super
rescue ArgumentError => e
fail!(:invalid_ticket, 'Invalid SAML Response')
end
end
def auth_hash
OmniAuth::Utils.deep_merge(super, {
'uid' => @name_id
})
end
end
end
end

View File

@ -0,0 +1,38 @@
require "base64"
require "uuid"
require "zlib"
require "cgi"
module OmniAuth
module Strategies
class SAML
class AuthRequest
def create(settings, params = {})
uuid = "_" + UUID.new.generate
time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
request =
"<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"#{uuid}\" Version=\"2.0\" IssueInstant=\"#{time}\" ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" AssertionConsumerServiceURL=\"#{settings[:assertion_consumer_service_url]}\">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">#{settings[:issuer]}</saml:Issuer>\n" +
"<samlp:NameIDPolicy xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Format=\"#{settings[:name_identifier_format]}\" AllowCreate=\"true\"></samlp:NameIDPolicy>\n" +
"<samlp:RequestedAuthnContext xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Comparison=\"exact\">" +
"<saml:AuthnContextClassRef xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext>\n" +
"</samlp:AuthnRequest>"
deflated_request = Zlib::Deflate.deflate(request, 9)[2..-5]
base64_request = Base64.encode64(deflated_request)
encoded_request = CGI.escape(base64_request)
request_params = "?SAMLRequest=" + encoded_request
params.each_pair do |key, value|
request_params << "&#{key}=#{CGI.escape(value.to_s)}"
end
settings[:idp_sso_target_url] + request_params
end
end
end
end
end

View File

@ -0,0 +1,141 @@
require "time"
module OmniAuth
module Strategies
class SAML
class AuthResponse
ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
DSIG = "http://www.w3.org/2000/09/xmldsig#"
attr_accessor :options, :response, :document, :settings
def initialize(response, options = {})
raise ArgumentError.new("Response cannot be nil") if response.nil?
self.options = options
self.response = response
self.document = OmniAuth::Strategies::SAML::XMLSecurity::SignedDocument.new(Base64.decode64(response))
end
def is_valid?
validate(soft = true)
end
def validate!
validate(soft = false)
end
# The value of the user identifier as designated by the initialization request response
def name_id
@name_id ||= begin
node = REXML::XPath.first(document, "/p:Response/a:Assertion[@ID='#{document.signed_element_id[1,document.signed_element_id.size]}']/a:Subject/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION })
node ||= REXML::XPath.first(document, "/p:Response[@ID='#{document.signed_element_id[1,document.signed_element_id.size]}']/a:Assertion/a:Subject/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION })
node.nil? ? nil : node.text
end
end
# A hash of alle the attributes with the response. Assuming there is only one value for each key
def attributes
@attr_statements ||= begin
result = {}
stmt_element = REXML::XPath.first(document, "/p:Response/a:Assertion/a:AttributeStatement", { "p" => PROTOCOL, "a" => ASSERTION })
return {} if stmt_element.nil?
stmt_element.elements.each do |attr_element|
name = attr_element.attributes["Name"]
value = attr_element.elements.first.text
result[name] = value
end
result.keys.each do |key|
result[key.intern] = result[key]
end
result
end
end
# When this user session should expire at latest
def session_expires_at
@expires_at ||= begin
node = REXML::XPath.first(document, "/p:Response/a:Assertion/a:AuthnStatement", { "p" => PROTOCOL, "a" => ASSERTION })
parse_time(node, "SessionNotOnOrAfter")
end
end
# Conditions (if any) for the assertion to run
def conditions
@conditions ||= begin
REXML::XPath.first(document, "/p:Response/a:Assertion[@ID='#{document.signed_element_id[1,document.signed_element_id.size]}']/a:Conditions", { "p" => PROTOCOL, "a" => ASSERTION })
end
end
private
def validation_error(message)
raise OmniAuth::Strategies::SAML::ValidationError.new(message)
end
def validate(soft = true)
validate_response_state(soft) &&
validate_conditions(soft) &&
document.validate(get_fingerprint, soft)
end
def validate_response_state(soft = true)
if response.empty?
return soft ? false : validation_error("Blank response")
end
if settings.nil?
return soft ? false : validation_error("No settings on response")
end
if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
return soft ? false : validation_error("No fingerprint or certificate on settings")
end
true
end
def get_fingerprint
if settings.idp_cert
cert = OpenSSL::X509::Certificate.new(settings.idp_cert)
Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":")
else
settings.idp_cert_fingerprint
end
end
def validate_conditions(soft = true)
return true if conditions.nil?
return true if options[:skip_conditions]
if not_before = parse_time(conditions, "NotBefore")
if Time.now.utc < not_before
return soft ? false : validation_error("Current time is earlier than NotBefore condition")
end
end
if not_on_or_after = parse_time(conditions, "NotOnOrAfter")
if Time.now.utc >= not_on_or_after
return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
end
end
true
end
def parse_time(node, attribute)
if node && node.attributes[attribute]
Time.parse(node.attributes[attribute])
end
end
end
end
end
end

View File

@ -0,0 +1,8 @@
module OmniAuth
module Strategies
class SAML
class ValidationError < Exception
end
end
end
end

View File

@ -0,0 +1,126 @@
# The contents of this file are subject to the terms
# of the Common Development and Distribution License
# (the License). You may not use this file except in
# compliance with the License.
#
# You can obtain a copy of the License at
# https://opensso.dev.java.net/public/CDDLv1.0.html or
# opensso/legal/CDDLv1.0.txt
# See the License for the specific language governing
# permission and limitations under the License.
#
# When distributing Covered Code, include this CDDL
# Header Notice in each file and include the License file
# at opensso/legal/CDDLv1.0.txt.
# If applicable, add the following below the CDDL Header,
# with the fields enclosed by brackets [] replaced by
# your own identifying information:
# "Portions Copyrighted [year] [name of copyright owner]"
#
# $Id: xml_sec.rb,v 1.6 2007/10/24 00:28:41 todddd Exp $
#
# Copyright 2007 Sun Microsystems Inc. All Rights Reserved
# Portions Copyrighted 2007 Todd W Saxton.
require 'rubygems'
require "rexml/document"
require "rexml/xpath"
require "openssl"
require "xmlcanonicalizer"
require "digest/sha1"
module OmniAuth
module Strategies
class SAML
module XMLSecurity
class SignedDocument < REXML::Document
DSIG = "http://www.w3.org/2000/09/xmldsig#"
attr_accessor :signed_element_id
def initialize(response)
super(response)
extract_signed_element_id
end
def validate(idp_cert_fingerprint, soft = true)
# get cert from response
base64_cert = self.elements["//ds:X509Certificate"].text
cert_text = Base64.decode64(base64_cert)
cert = OpenSSL::X509::Certificate.new(cert_text)
# check cert matches registered idp cert
fingerprint = Digest::SHA1.hexdigest(cert.to_der)
if fingerprint != idp_cert_fingerprint.gsub(/[^a-zA-Z0-9]/,"").downcase
return soft ? false : (raise OmniAuth::Strategies::SAML::ValidationError.new("Fingerprint mismatch"))
end
validate_doc(base64_cert, soft)
end
def validate_doc(base64_cert, soft = true)
# validate references
# check for inclusive namespaces
inclusive_namespaces = []
inclusive_namespace_element = REXML::XPath.first(self, "//ec:InclusiveNamespaces")
if inclusive_namespace_element
prefix_list = inclusive_namespace_element.attributes.get_attribute('PrefixList').value
inclusive_namespaces = prefix_list.split(" ")
end
# remove signature node
sig_element = REXML::XPath.first(self, "//ds:Signature", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"})
sig_element.remove
# check digests
REXML::XPath.each(sig_element, "//ds:Reference", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}) do |ref|
uri = ref.attributes.get_attribute("URI").value
hashed_element = REXML::XPath.first(self, "//[@ID='#{uri[1,uri.size]}']")
canoner = XML::Util::XmlCanonicalizer.new(false, true)
canoner.inclusive_namespaces = inclusive_namespaces if canoner.respond_to?(:inclusive_namespaces) && !inclusive_namespaces.empty?
canon_hashed_element = canoner.canonicalize(hashed_element)
hash = Base64.encode64(Digest::SHA1.digest(canon_hashed_element)).chomp
digest_value = REXML::XPath.first(ref, "//ds:DigestValue", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}).text
if hash != digest_value
return soft ? false : (raise OmniAuth::Strategies::SAML::ValidationError.new("Digest mismatch"))
end
end
# verify signature
canoner = XML::Util::XmlCanonicalizer.new(false, true)
signed_info_element = REXML::XPath.first(sig_element, "//ds:SignedInfo", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"})
canon_string = canoner.canonicalize(signed_info_element)
base64_signature = REXML::XPath.first(sig_element, "//ds:SignatureValue", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}).text
signature = Base64.decode64(base64_signature)
# get certificate object
cert_text = Base64.decode64(base64_cert)
cert = OpenSSL::X509::Certificate.new(cert_text)
if !cert.public_key.verify(OpenSSL::Digest::SHA1.new, signature, canon_string)
return soft ? false : (raise OmniAuth::Strategies::SAML::ValidationError.new("Key validation error"))
end
return true
end
private
def extract_signed_element_id
reference_element = REXML::XPath.first(self, "//ds:Signature/ds:SignedInfo/ds:Reference", {"ds"=>DSIG})
self.signed_element_id = reference_element.attribute("URI").value unless reference_element.nil?
end
end
end
end
end
end

View File

@ -8,6 +8,7 @@ Gem::Specification.new do |gem|
gem.add_dependency 'oa-core', OmniAuth::Version::STRING
gem.add_dependency 'pyu-ruby-sasl', '~> 0.0.3.1'
gem.add_dependency 'rubyntlm', '~> 0.1.1'
gem.add_dependency 'XMLCanonicalizer', '~> 1.0.1'
gem.add_development_dependency 'rack-test', '~> 0.5'
gem.add_development_dependency 'rake', '~> 0.8'
gem.add_development_dependency 'rdiscount', '~> 1.6'
@ -15,9 +16,9 @@ Gem::Specification.new do |gem|
gem.add_development_dependency 'simplecov', '~> 0.4'
gem.add_development_dependency 'webmock', '~> 1.7'
gem.add_development_dependency 'yard', '~> 0.7'
gem.authors = ['James A. Rosen', 'Ping Yu', 'Michael Bleigh', 'Erik Michaels-Ober']
gem.authors = ['James A. Rosen', 'Ping Yu', 'Michael Bleigh', 'Erik Michaels-Ober', 'Raecoo Cao']
gem.description = %q{Enterprise strategies for OmniAuth.}
gem.email = ['james.a.rosen@gmail.com', 'ping@intridea.com', 'michael@intridea.com', 'sferik@gmail.com']
gem.email = ['james.a.rosen@gmail.com', 'ping@intridea.com', 'michael@intridea.com', 'sferik@gmail.com', 'raecoo@intridea.com']
gem.files = `git ls-files`.split("\n")
gem.homepage = 'http://github.com/intridea/omniauth'
gem.name = 'oa-enterprise'