mirror of
https://github.com/omniauth/omniauth.git
synced 2022-11-09 12:31:49 -05:00
adds SAML strategy
This commit is contained in:
parent
abe1d1c9aa
commit
3079ffcdae
8 changed files with 401 additions and 3 deletions
|
@ -67,6 +67,39 @@ 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
|
||||
|
|
|
@ -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
|
||||
|
|
50
oa-enterprise/lib/omniauth/strategies/saml.rb
Normal file
50
oa-enterprise/lib/omniauth/strategies/saml.rb
Normal 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
|
38
oa-enterprise/lib/omniauth/strategies/saml/auth_request.rb
Normal file
38
oa-enterprise/lib/omniauth/strategies/saml/auth_request.rb
Normal 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
|
141
oa-enterprise/lib/omniauth/strategies/saml/auth_response.rb
Normal file
141
oa-enterprise/lib/omniauth/strategies/saml/auth_response.rb
Normal 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
|
|
@ -0,0 +1,8 @@
|
|||
module OmniAuth
|
||||
module Strategies
|
||||
class SAML
|
||||
class ValidationError < Exception
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
126
oa-enterprise/lib/omniauth/strategies/saml/xml_security.rb
Normal file
126
oa-enterprise/lib/omniauth/strategies/saml/xml_security.rb
Normal 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
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue