chorn-warden-googleapps 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+