chorn-warden-googleapps 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,117 @@
1
+ require 'devise/strategies/base'
2
+
3
+ module Devise
4
+ module Strategies
5
+ # Default strategy for signing in a user, based on his email and password.
6
+ # Redirects to sign_in page if it's not authenticated
7
+ class GoogleApps < Base
8
+ AxEmail = 'http://axschema.org/contact/email'
9
+ AxFirstName = 'http://axschema.org/namePerson/first'
10
+ AxLastName = 'http://axschema.org/namePerson/last'
11
+
12
+ # Need to make sure that we have a pure representation of the query string.
13
+ # Rails adds an "action" parameter which causes the openid gem to error
14
+ def params
15
+ @params ||= Rack::Utils.parse_query(request.query_string)
16
+ end
17
+
18
+ def authenticate!
19
+ if params['openid.mode']
20
+ response = consumer.complete(params, absolute_url(request, request.path))
21
+ case response.status.to_s
22
+ when 'success'
23
+ profile_data = extract_ax_profile(response)
24
+
25
+ resource = mapping.to.find_by_identity_url(response.display_identifier)
26
+
27
+ unless resource
28
+ resource = mapping.to.new
29
+ resource.first_name = profile_data[:first_name].join(' ')
30
+ resource.last_name = profile_data[:last_name].join(' ')
31
+ resource.email = profile_data[:email].join(' ')
32
+ resource.identity_url = response.display_identifier
33
+ begin
34
+ pwd = (Kernel.rand*1000000000000000).to_i
35
+ resource.password = pwd
36
+ resource.password_confirmation = pwd
37
+ rescue Error => e
38
+ puts "You probably need authenticatable set on your model."
39
+ end
40
+ resource.save!
41
+ end
42
+
43
+ success!(resource)
44
+ when 'failure', 'setup_needed', 'cancel'
45
+ fail!('Cound not log in')
46
+ else
47
+ fail!("Unknown Response Status: #{response.status.to_s}")
48
+ end
49
+ elsif params['RelayState']
50
+ raise GoogleAppsMisconfiguredError, "Warden::GoogleApps only works with OpenID Federed Login for Google Apps"
51
+ else
52
+ google_discovery = OpenID.discover(open_id_endpoint)
53
+ open_id_request = consumer.begin(google_discovery.first)
54
+ add_ax_fields(open_id_request)
55
+ redirect!(open_id_request.redirect_url(absolute_url(request), absolute_url(request)))
56
+ end
57
+ end
58
+
59
+ private
60
+ def consumer
61
+ @consumer ||= ::OpenID::Consumer.new(request.session, open_id_store)
62
+ end
63
+
64
+ def open_id_store
65
+ ::OpenID::Store::Filesystem.new("#{Dir.tmpdir}/tmp/openid")
66
+ end
67
+
68
+ def google_apps_domain
69
+ env['warden'].config[:google_apps_domain]
70
+ end
71
+
72
+ def domain
73
+ 'https://www.google.com/accounts/o8/site-xrds?hd=%s' % google_apps_domain
74
+ end
75
+
76
+ def endpoint
77
+ env['warden'].config[:google_apps_endpoint]
78
+ end
79
+
80
+ def open_id_endpoint
81
+ endpoint || domain
82
+ end
83
+
84
+ def add_ax_fields(open_id_request)
85
+ ax_request = ::OpenID::AX::FetchRequest.new
86
+
87
+ [ AxEmail, AxFirstName, AxLastName ].each do |field|
88
+ ax_request.add(::OpenID::AX::AttrInfo.new(field, nil, true))
89
+ end
90
+ open_id_request.add_extension(ax_request)
91
+ end
92
+
93
+ def extract_ax_profile(open_id_response)
94
+ profile = { }
95
+ if ax_response = OpenID::AX::FetchResponse.from_success_response(open_id_response)
96
+ profile_data = ax_response.data
97
+ profile[:email] = profile_data[AxEmail]
98
+ profile[:last_name] = profile_data[AxLastName]
99
+ profile[:first_name] = profile_data[AxFirstName]
100
+ end
101
+ profile
102
+ end
103
+
104
+ def absolute_url(request, suffix = nil)
105
+ port_part = case request.scheme
106
+ when "http"
107
+ request.port == 80 ? "" : ":#{request.port}"
108
+ when "https"
109
+ request.port == 443 ? "" : ":#{request.port}"
110
+ end
111
+ "#{request.scheme}://#{request.host}#{port_part}#{suffix}"
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ Warden::Strategies.add(:devise_google_apps, Devise::Strategies::GoogleApps)
@@ -0,0 +1,267 @@
1
+ require "openid"
2
+ require "openid/fetchers"
3
+ require "openid/consumer/discovery"
4
+ require 'rexml/document'
5
+ require 'rexml/element'
6
+ require 'rexml/xpath'
7
+ require 'openssl'
8
+ require 'base64'
9
+
10
+ # Copyright 2009 Google Inc.
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License")
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+
24
+ # Extends ruby-openid to support the discovery protocol used by Google Apps. Usage is
25
+ # generally simple. Where using ruby-openid's Consumer, add the line
26
+ #
27
+ # require 'gapps_openid'
28
+ #
29
+ # Caching of discovery information is enabled when used with rails. In other environments,
30
+ # a cache can be set via:
31
+ #
32
+ # OpenID::GoogleDiscovery.cache = ...
33
+ #
34
+ # The cache must implement methods read(key) and write(key,value)
35
+ #
36
+ # In some cases additional setup is required, particularly to set the location of trusted
37
+ # root certificates for validating XRDS signatures. If standard locations don't work, additional
38
+ # files and directories can be added via:
39
+ #
40
+ # OpenID::SimpleSign.store.add_file(path_to_cacert_pem)
41
+ #
42
+ # or
43
+ #
44
+ # OpenID::SimpleSign.store.add_path(path_to_ca_dir)
45
+ #
46
+ # TODO:
47
+ # - Memcache support for caching host-meta and site XRDS docs
48
+ # - Better packaging (gem/rails)
49
+ module OpenID
50
+
51
+ class << self
52
+ alias_method :default_discover, :discover
53
+ end
54
+
55
+ def self.discover(uri)
56
+ # Set the CA file for the fetcher so the HTTPS requests will not issue warnings.
57
+ fetcher.ca_file = File.join(File.dirname(__FILE__), 'ca-bundle.crt')
58
+ discovery = GoogleDiscovery.new
59
+ info = discovery.perform_discovery(uri)
60
+ if not info.nil?
61
+ return info
62
+ end
63
+ return self.default_discover(uri)
64
+ end
65
+
66
+ # Handles the bulk of Google's modified discovery prototcol
67
+ # See http://groups.google.com/group/google-federated-login-api/web/openid-discovery-for-hosted-domains
68
+ class GoogleDiscovery
69
+
70
+ begin
71
+ @@cache = RAILS_CACHE
72
+ rescue
73
+ @@cache = nil
74
+ end
75
+
76
+ NAMESPACES = {
77
+ 'xrds' => 'xri://$xrd*($v*2.0)',
78
+ 'xrd' => 'xri://$xrds',
79
+ 'openid' => 'http://namespace.google.com/openid/xmlns'
80
+ }
81
+
82
+ # Main entry point for discovery. Attempts to detect whether or not the URI is a raw domain name ('mycompany.com')
83
+ # vs. a user's claimed ID ('http://mycompany.com/openid?id=12345') and performs the site or user discovery appropriately
84
+ def perform_discovery(uri)
85
+ begin
86
+ parsed_uri = URI::parse(uri)
87
+ if parsed_uri.scheme.nil?
88
+ return discover_site(uri)
89
+ end
90
+ return discover_user(parsed_uri.host, uri)
91
+ rescue
92
+ # If we fail, just return nothing and fallback on default discovery mechanisms
93
+ return nil
94
+ end
95
+ end
96
+
97
+ # Handles discovery for a user's claimed ID.
98
+ def discover_user(domain, claimed_id)
99
+ url = fetch_host_meta(domain)
100
+ if url.nil?
101
+ return nil # Not a Google Apps domain
102
+ end
103
+ xrds = fetch_xrds(domain, url)
104
+ user_url, authority = get_user_xrds_url(xrds, claimed_id)
105
+ user_xrds = fetch_xrds(authority, user_url, false)
106
+ return if user_xrds.nil?
107
+
108
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(claimed_id, user_xrds)
109
+ return [claimed_id, OpenID.get_op_or_user_services(endpoints)]
110
+ end
111
+
112
+ # Handles discovery for a domain
113
+ def discover_site(domain)
114
+ url = fetch_host_meta(domain)
115
+ if url.nil?
116
+ return nil # Not a Google Apps domain
117
+ end
118
+ xrds = fetch_xrds(domain, url)
119
+ unless xrds.nil?
120
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(domain, xrds)
121
+ return [domain, OpenID.get_op_or_user_services(endpoints)]
122
+ end
123
+ return nil
124
+ end
125
+
126
+ # Kickstart the discovery process by checking against Google's well-known location for hosted domains.
127
+ # This gives us the location of the site's XRDS doc
128
+ def fetch_host_meta(domain)
129
+ cached_value = get_cache(domain)
130
+ return cached_value unless cached_value.nil?
131
+
132
+ host_meta_url = "https://www.google.com/accounts/o8/.well-known/host-meta?hd=#{CGI::escape(domain)}"
133
+ http_resp = OpenID.fetch(host_meta_url)
134
+ if http_resp.code != "200" and http_resp.code != "206"
135
+ return nil
136
+ end
137
+ matches = /Link: <(.*)>/.match( http_resp.body )
138
+ if matches.nil?
139
+ return nil
140
+ end
141
+ put_cache(domain, matches[1])
142
+ return matches[1]
143
+ end
144
+
145
+ # Fetches the XRDS and verifies the signature and authority for the doc
146
+ def fetch_xrds(authority, url, cache=true)
147
+ return if url.nil?
148
+
149
+ cached_xrds = get_cache(url)
150
+ return cached_xrds unless cached_xrds.nil?
151
+
152
+ http_resp = OpenID.fetch(url)
153
+ return if http_resp.code != "200" and http_resp.code != "206"
154
+
155
+ body = http_resp.body
156
+ signature = http_resp["Signature"]
157
+ signed_by = SimpleSign.verify(body, signature)
158
+ if !signed_by.casecmp(authority) or !signed_by.casecmp('hosted-id.google.com')
159
+ return false # Signed, but not by the right domain.
160
+ end
161
+
162
+
163
+ # Everything is OK
164
+ if cache
165
+ put_cache(url, body)
166
+ end
167
+ return body
168
+ end
169
+
170
+ # Process the URITemplate in the XRDS to derive the location of the claimed id's XRDS
171
+ def get_user_xrds_url(xrds, claimed_id)
172
+ types_to_match = ['http://www.iana.org/assignments/relation/describedby']
173
+ services = OpenID::Yadis::apply_filter(claimed_id, xrds)
174
+ services.each do | service |
175
+ if service.match_types(types_to_match)
176
+ template = REXML::XPath.first(service.service_element, '//openid:URITemplate', NAMESPACES)
177
+ authority = REXML::XPath.first(service.service_element, '//openid:NextAuthority', NAMESPACES)
178
+ url = template.text.gsub('{%uri}', CGI::escape(claimed_id))
179
+ return [url, authority.text]
180
+ end
181
+ end
182
+ end
183
+
184
+ def put_cache(key, item)
185
+ return if @@cache.nil?
186
+ @@cache.write("__GAPPS_OPENID__#{key}", item)
187
+ end
188
+
189
+ def get_cache(key)
190
+ return nil if @@cache.nil?
191
+ return @@cache.read("__GAPPS_OPENID__#{key}")
192
+ end
193
+ end
194
+
195
+ # Basic implementation of the XML Simple Sign algorithm. Currently only supports
196
+ # RSA-SHA1
197
+ class SimpleSign
198
+
199
+ @@store = nil
200
+
201
+ C14N_RAW_OCTETS = 'http://docs.oasis-open.org/xri/xrd/2009/01#canonicalize-raw-octets'
202
+ SIGN_RSA_SHA1 = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
203
+
204
+ NAMESPACES = {
205
+ 'ds' => 'http://www.w3.org/2000/09/xmldsig#',
206
+ 'xrds' => 'xri://xrds'
207
+ }
208
+
209
+ # Initialize the store
210
+ def self.store
211
+ if @@store.nil?
212
+ ca_bundle_path = File.join(File.dirname(__FILE__), 'ca-bundle.crt')
213
+ @@store = OpenSSL::X509::Store.new
214
+ @@store.set_default_paths
215
+ @@store.add_file(ca_bundle_path)
216
+ end
217
+ return @@store
218
+ end
219
+
220
+ # Extracts the signer's certificates from the XML
221
+ def self.parse_certificates(doc)
222
+ certs = []
223
+ REXML::XPath.each(doc, "//ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate", NAMESPACES ) { | encoded |
224
+ encoded = encoded.text.strip.scan(/.{1,64}/).join("\n")
225
+ encoded = "-----BEGIN CERTIFICATE-----\n#{encoded}\n-----END CERTIFICATE-----\n"
226
+ cert = OpenSSL::X509::Certificate.new(encoded)
227
+ certs << cert
228
+ }
229
+ return certs
230
+ end
231
+
232
+ # Verifies the chain of trust for the signing certificates
233
+ def self.valid_chain?(chain)
234
+ if chain.nil? or chain.empty?
235
+ return false
236
+ end
237
+ cert = chain.shift
238
+ if self.store.verify(cert)
239
+ return true
240
+ end
241
+ if chain.empty? or not cert.verify(chain.first.public_key)
242
+ return false
243
+ end
244
+ return self.valid_chain?(chain)
245
+ end
246
+
247
+ # Verifies the signature of the doc, returning the CN of the signer if valid
248
+ def self.verify(xml, signature_value)
249
+ raise "Missing signature value" if signature_value.nil?
250
+ decoded_sig = Base64.decode64(signature_value)
251
+
252
+ doc = REXML::Document.new(xml)
253
+ certs = self.parse_certificates(doc)
254
+ raise "No signature in document" if certs.nil? or certs.empty?
255
+
256
+ signing_certificate = certs.first
257
+ raise "Invalid signature" if !signing_certificate.public_key.verify(OpenSSL::Digest::SHA1.new, decoded_sig, xml)
258
+ raise "Certificate chain not valid" if !self.valid_chain?(certs)
259
+
260
+ # Signature is valid, return CN of the subject
261
+ subject = signing_certificate.subject.to_a
262
+ signed_by = subject.last[1]
263
+ return signed_by
264
+ end
265
+ end
266
+
267
+ end
@@ -0,0 +1,9 @@
1
+ module Warden
2
+ module GoogleApps
3
+ class User < Struct.new(:email, :first_name, :last_name, :identity_url)
4
+ def full_name
5
+ "#{first_name} #{last_name}"
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module Warden
2
+ module GoogleApps
3
+ VERSION = "0.1.1"
4
+ end
5
+ end
@@ -0,0 +1,92 @@
1
+ Warden::Strategies.add(:google_apps) do
2
+ AxEmail = 'http://axschema.org/contact/email'
3
+ AxFirstName = 'http://axschema.org/namePerson/first'
4
+ AxLastName = 'http://axschema.org/namePerson/last'
5
+
6
+ # Need to make sure that we have a pure representation of the query string.
7
+ # Rails adds an "action" parameter which causes the openid gem to error
8
+ def params
9
+ @params ||= Rack::Utils.parse_query(request.query_string)
10
+ end
11
+
12
+ def authenticate!
13
+ if params['openid.mode']
14
+ response = consumer.complete(params, absolute_url(request, request.path))
15
+ case response.status.to_s
16
+ when 'success'
17
+ profile_data = extract_ax_profile(response)
18
+ user = Warden::GoogleApps::User.new(profile_data[:email],
19
+ profile_data[:first_name],
20
+ profile_data[:last_name],
21
+ response.display_identifier)
22
+ success!(user)
23
+ when 'failure', 'setup_needed', 'cancel'
24
+ fail!('Cound not log in')
25
+ else
26
+ fail!("Unknown Response Status: #{response.status.to_s}")
27
+ end
28
+ elsif params['RelayState']
29
+ raise GoogleAppsMisconfiguredError, "Warden::GoogleApps only works with OpenID Federed Login for Google Apps"
30
+ else
31
+ google_discovery = OpenID.discover(open_id_endpoint)
32
+ open_id_request = consumer.begin(google_discovery.first)
33
+ add_ax_fields(open_id_request)
34
+ redirect!(open_id_request.redirect_url(absolute_url(request), absolute_url(request)))
35
+ end
36
+ end
37
+
38
+ private
39
+ def consumer
40
+ @consumer ||= ::OpenID::Consumer.new(request.session, open_id_store)
41
+ end
42
+
43
+ def open_id_store
44
+ ::OpenID::Store::Filesystem.new("#{Dir.tmpdir}/tmp/openid")
45
+ end
46
+
47
+ def google_apps_domain
48
+ env['warden'].config[:google_apps_domain]
49
+ end
50
+
51
+ def domain
52
+ 'https://www.google.com/accounts/o8/site-xrds?hd=%s' % google_apps_domain
53
+ end
54
+
55
+ def endpoint
56
+ env['warden'].config[:google_apps_endpoint]
57
+ end
58
+
59
+ def open_id_endpoint
60
+ endpoint || domain
61
+ end
62
+
63
+ def add_ax_fields(open_id_request)
64
+ ax_request = ::OpenID::AX::FetchRequest.new
65
+
66
+ [ AxEmail, AxFirstName, AxLastName ].each do |field|
67
+ ax_request.add(::OpenID::AX::AttrInfo.new(field, nil, true))
68
+ end
69
+ open_id_request.add_extension(ax_request)
70
+ end
71
+
72
+ def extract_ax_profile(open_id_response)
73
+ profile = { }
74
+ if ax_response = OpenID::AX::FetchResponse.from_success_response(open_id_response)
75
+ profile_data = ax_response.data
76
+ profile[:email] = profile_data[AxEmail]
77
+ profile[:last_name] = profile_data[AxLastName]
78
+ profile[:first_name] = profile_data[AxFirstName]
79
+ end
80
+ profile
81
+ end
82
+
83
+ def absolute_url(request, suffix = nil)
84
+ port_part = case request.scheme
85
+ when "http"
86
+ request.port == 80 ? "" : ":#{request.port}"
87
+ when "https"
88
+ request.port == 443 ? "" : ":#{request.port}"
89
+ end
90
+ "#{request.scheme}://#{request.host}#{port_part}#{suffix}"
91
+ end
92
+ end
@@ -0,0 +1,18 @@
1
+ require 'warden'
2
+ require 'tmpdir'
3
+ require 'openid/consumer'
4
+ require 'openid/store/filesystem'
5
+ require 'openid/extensions/sreg'
6
+ require 'openid/extensions/ax'
7
+
8
+ module Warden
9
+ module GoogleApps
10
+ class GoogleAppsMisconfiguredError < StandardError; end
11
+ end
12
+ end
13
+
14
+ require File.dirname(__FILE__)+'/warden-googleapps/user'
15
+ require File.dirname(__FILE__)+'/warden-googleapps/version'
16
+ require File.dirname(__FILE__)+'/warden-googleapps/warden_strategy'
17
+ require File.dirname(__FILE__)+'/warden-googleapps/devise_strategy' if Object.const_defined?(:Devise)
18
+ require File.dirname(__FILE__)+'/warden-googleapps/gapps_openid'
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chorn-warden-googleapps
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 2
8
+ - 0
9
+ version: 0.2.0
10
+ platform: ruby
11
+ authors:
12
+ - Corey Donohoe
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-06-17 00:00:00 -04:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: warden
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ~>
27
+ - !ruby/object:Gem::Version
28
+ segments:
29
+ - 0
30
+ - 10
31
+ version: "0.10"
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: ruby-openid
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ none: false
39
+ requirements:
40
+ - - ~>
41
+ - !ruby/object:Gem::Version
42
+ segments:
43
+ - 2
44
+ - 1
45
+ - 7
46
+ version: 2.1.7
47
+ type: :runtime
48
+ version_requirements: *id002
49
+ description: Warden / Devise strategies to use Google's Federated OpenID with Google Apps
50
+ email: atmos@atmos.org
51
+ executables: []
52
+
53
+ extensions: []
54
+
55
+ extra_rdoc_files:
56
+ - LICENSE
57
+ files:
58
+ - LICENSE
59
+ - README.md
60
+ - Rakefile
61
+ - lib/warden-googleapps/ca-bundle.crt
62
+ - lib/warden-googleapps/gapps_openid.rb
63
+ - lib/warden-googleapps/warden_strategy.rb
64
+ - lib/warden-googleapps/devise_strategy.rb
65
+ - lib/warden-googleapps/user.rb
66
+ - lib/warden-googleapps/version.rb
67
+ - lib/warden-googleapps.rb
68
+ has_rdoc: true
69
+ homepage: http://github.com/chorn/warden-googleapps
70
+ licenses: []
71
+
72
+ post_install_message:
73
+ rdoc_options: []
74
+
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ none: false
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ segments:
83
+ - 0
84
+ version: "0"
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ none: false
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ segments:
91
+ - 0
92
+ version: "0"
93
+ requirements: []
94
+
95
+ rubyforge_project:
96
+ rubygems_version: 1.3.7
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: Warden & Devise strategies to use Google's Federated OpenID with Google Apps
100
+ test_files: []
101
+