sundawg_contacts 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +18 -0
- data/MIT-LICENSE +20 -0
- data/Manifest +30 -0
- data/README.rdoc +47 -0
- data/Rakefile +68 -0
- data/lib/config/contacts.yml +10 -0
- data/lib/contacts.rb +46 -0
- data/lib/contacts/flickr.rb +133 -0
- data/lib/contacts/google.rb +353 -0
- data/lib/contacts/version.rb +9 -0
- data/lib/contacts/windows_live.rb +164 -0
- data/lib/contacts/yahoo.rb +242 -0
- data/spec/contact_spec.rb +41 -0
- data/spec/feeds/contacts.yml +10 -0
- data/spec/feeds/flickr/auth.getFrob.xml +4 -0
- data/spec/feeds/flickr/auth.getToken.xml +5 -0
- data/spec/feeds/google-many.xml +48 -0
- data/spec/feeds/google-single.xml +46 -0
- data/spec/feeds/wl_contacts.xml +29 -0
- data/spec/feeds/yh_contacts.txt +119 -0
- data/spec/feeds/yh_credential.xml +28 -0
- data/spec/flickr/auth_spec.rb +80 -0
- data/spec/gmail/auth_spec.rb +70 -0
- data/spec/gmail/fetching_spec.rb +196 -0
- data/spec/rcov.opts +2 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +84 -0
- data/spec/windows_live/windows_live_spec.rb +34 -0
- data/spec/yahoo/yahoo_spec.rb +83 -0
- data/sundawg_contacts.gemspec +33 -0
- data/vendor/windows_live_login.rb +1156 -0
- metadata +119 -0
@@ -0,0 +1,196 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'contacts/google'
|
3
|
+
|
4
|
+
describe Contacts::Google do
|
5
|
+
|
6
|
+
before :each do
|
7
|
+
@gmail = create
|
8
|
+
end
|
9
|
+
|
10
|
+
def create
|
11
|
+
Contacts::Google.new('dummytoken')
|
12
|
+
end
|
13
|
+
|
14
|
+
after :each do
|
15
|
+
FakeWeb.clean_registry
|
16
|
+
end
|
17
|
+
|
18
|
+
describe 'fetches contacts feed via HTTP GET' do
|
19
|
+
it 'with defaults' do
|
20
|
+
FakeWeb::register_uri(:get, 'https://www.google.com/m8/feeds/contacts/default/thin',
|
21
|
+
:body => 'thin results',
|
22
|
+
:verify => lambda { |req|
|
23
|
+
req['Authorization'].should == %(AuthSub token="dummytoken")
|
24
|
+
req['Accept-Encoding'].should == 'gzip'
|
25
|
+
req['User-Agent'].should == "Ruby Contacts v#{Contacts::VERSION::STRING} (gzip)"
|
26
|
+
}
|
27
|
+
)
|
28
|
+
|
29
|
+
response = @gmail.get({})
|
30
|
+
response.body.should == 'thin results'
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'with explicit user ID and full projection' do
|
34
|
+
@gmail = Contacts::Google.new('dummytoken', 'person@example.com')
|
35
|
+
@gmail.projection = 'full'
|
36
|
+
|
37
|
+
FakeWeb::register_uri(:get, 'https://www.google.com/m8/feeds/contacts/person%40example.com/full',
|
38
|
+
:body => 'full results'
|
39
|
+
)
|
40
|
+
|
41
|
+
response = @gmail.get({})
|
42
|
+
response.body.should == 'full results'
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'handles a normal response body' do
|
47
|
+
response = mock('HTTP response')
|
48
|
+
@gmail.expects(:get).returns(response)
|
49
|
+
|
50
|
+
response.expects(:'[]').with('Content-Encoding').returns(nil)
|
51
|
+
response.expects(:body).returns('<feed/>')
|
52
|
+
|
53
|
+
@gmail.expects(:parse_contacts).with('<feed/>')
|
54
|
+
@gmail.contacts
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'handles gzipped response' do
|
58
|
+
response = mock('HTTP response')
|
59
|
+
@gmail.expects(:get).returns(response)
|
60
|
+
|
61
|
+
gzipped = StringIO.new
|
62
|
+
gzwriter = Zlib::GzipWriter.new gzipped
|
63
|
+
gzwriter.write(('a'..'z').to_a.join)
|
64
|
+
gzwriter.close
|
65
|
+
|
66
|
+
response.expects(:'[]').with('Content-Encoding').returns('gzip')
|
67
|
+
response.expects(:body).returns gzipped.string
|
68
|
+
|
69
|
+
@gmail.expects(:parse_contacts).with('abcdefghijklmnopqrstuvwxyz')
|
70
|
+
@gmail.contacts
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'raises a fetching error when something goes awry' do
|
74
|
+
FakeWeb::register_uri(:get, 'https://www.google.com/m8/feeds/contacts/default/thin',
|
75
|
+
:status => [404, 'YOU FAIL']
|
76
|
+
)
|
77
|
+
|
78
|
+
lambda {
|
79
|
+
@gmail.get({})
|
80
|
+
}.should raise_error(Net::HTTPServerException)
|
81
|
+
end
|
82
|
+
|
83
|
+
it 'parses the resulting feed into name/email pairs' do
|
84
|
+
@gmail.stubs(:get)
|
85
|
+
@gmail.expects(:response_body).returns(sample_xml('google-single'))
|
86
|
+
|
87
|
+
found = @gmail.contacts
|
88
|
+
found.size.should == 1
|
89
|
+
contact = found.first
|
90
|
+
contact.name.should == 'Fitzgerald'
|
91
|
+
contact.emails.should == ['fubar@gmail.com']
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'parses a complex feed into name/email pairs' do
|
95
|
+
@gmail.stubs(:get)
|
96
|
+
@gmail.expects(:response_body).returns(sample_xml('google-many'))
|
97
|
+
|
98
|
+
found = @gmail.contacts
|
99
|
+
found.size.should == 3
|
100
|
+
found[0].name.should == 'Elizabeth Bennet'
|
101
|
+
found[0].emails.should == ['liz@gmail.com', 'liz@example.org']
|
102
|
+
found[1].name.should == 'William Paginate'
|
103
|
+
found[1].emails.should == ['will_paginate@googlegroups.com']
|
104
|
+
found[2].name.should be_nil
|
105
|
+
found[2].emails.should == ['anonymous@example.com']
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'makes modification time available after parsing' do
|
109
|
+
@gmail.updated_at.should be_nil
|
110
|
+
@gmail.stubs(:get)
|
111
|
+
@gmail.expects(:response_body).returns(sample_xml('google-single'))
|
112
|
+
|
113
|
+
@gmail.contacts
|
114
|
+
u = @gmail.updated_at
|
115
|
+
u.year.should == 2008
|
116
|
+
u.day.should == 5
|
117
|
+
@gmail.updated_at_string.should == '2008-03-05T12:36:38.836Z'
|
118
|
+
end
|
119
|
+
|
120
|
+
describe 'GET query parameter handling' do
|
121
|
+
|
122
|
+
before :each do
|
123
|
+
@gmail = create
|
124
|
+
@gmail.stubs(:response_body)
|
125
|
+
@gmail.stubs(:parse_contacts)
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'abstracts ugly parameters behind nicer ones' do
|
129
|
+
expect_params 'max-results' => '25',
|
130
|
+
'orderby' => 'lastmodified',
|
131
|
+
'sortorder' => 'ascending',
|
132
|
+
'start-index' => '11',
|
133
|
+
'updated-min' => 'datetime'
|
134
|
+
|
135
|
+
@gmail.contacts :limit => 25,
|
136
|
+
:offset => 10,
|
137
|
+
:order => 'lastmodified',
|
138
|
+
:descending => false,
|
139
|
+
:updated_after => 'datetime'
|
140
|
+
end
|
141
|
+
|
142
|
+
it 'should have implicit :descending with :order' do
|
143
|
+
expect_params 'orderby' => 'lastmodified',
|
144
|
+
'sortorder' => 'descending',
|
145
|
+
'max-results' => '200'
|
146
|
+
|
147
|
+
@gmail.contacts :order => 'lastmodified'
|
148
|
+
end
|
149
|
+
|
150
|
+
it 'should have default :limit of 200' do
|
151
|
+
expect_params 'max-results' => '200'
|
152
|
+
@gmail.contacts
|
153
|
+
end
|
154
|
+
|
155
|
+
it 'should skip nil values in parameters' do
|
156
|
+
expect_params 'start-index' => '1'
|
157
|
+
@gmail.contacts :limit => nil, :offset => 0
|
158
|
+
end
|
159
|
+
|
160
|
+
def expect_params(params)
|
161
|
+
query_string = Contacts::Google.query_string(params)
|
162
|
+
FakeWeb::register_uri(:get, "https://www.google.com/m8/feeds/contacts/default/thin?#{query_string}", {})
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
|
167
|
+
describe 'Retrieving all contacts (in chunks)' do
|
168
|
+
|
169
|
+
before :each do
|
170
|
+
@gmail = create
|
171
|
+
end
|
172
|
+
|
173
|
+
it 'should make only one API call when no more is needed' do
|
174
|
+
@gmail.expects(:contacts).with(instance_of(Hash)).once.returns((0..8).to_a)
|
175
|
+
|
176
|
+
@gmail.all_contacts({}, 10).should == (0..8).to_a
|
177
|
+
end
|
178
|
+
|
179
|
+
it 'should make multiple calls to :contacts when needed' do
|
180
|
+
@gmail.expects(:contacts).with(has_entries(:offset => 0 , :limit => 10)).returns(( 0..9 ).to_a)
|
181
|
+
@gmail.expects(:contacts).with(has_entries(:offset => 10, :limit => 10)).returns((10..19).to_a)
|
182
|
+
@gmail.expects(:contacts).with(has_entries(:offset => 20, :limit => 10)).returns((20..24).to_a)
|
183
|
+
|
184
|
+
@gmail.all_contacts({}, 10).should == (0..24).to_a
|
185
|
+
end
|
186
|
+
|
187
|
+
it 'should make one extra API call when not sure whether there are more contacts' do
|
188
|
+
@gmail.expects(:contacts).with(has_entries(:offset => 0 , :limit => 10)).returns((0..9).to_a)
|
189
|
+
@gmail.expects(:contacts).with(has_entries(:offset => 10, :limit => 10)).returns([])
|
190
|
+
|
191
|
+
@gmail.all_contacts({}, 10).should == (0..9).to_a
|
192
|
+
end
|
193
|
+
|
194
|
+
end
|
195
|
+
|
196
|
+
end
|
data/spec/rcov.opts
ADDED
data/spec/spec.opts
ADDED
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
gem 'rspec', '~> 1.1.3'
|
3
|
+
require 'spec'
|
4
|
+
gem 'mocha', '~> 0.9.0'
|
5
|
+
require 'mocha'
|
6
|
+
|
7
|
+
require 'cgi'
|
8
|
+
require 'fake_web'
|
9
|
+
FakeWeb.allow_net_connect = false
|
10
|
+
|
11
|
+
module SampleFeeds
|
12
|
+
FEED_DIR = File.dirname(__FILE__) + '/feeds/'
|
13
|
+
|
14
|
+
def sample_xml(name)
|
15
|
+
File.read "#{FEED_DIR}#{name}.xml"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module HttpMocks
|
20
|
+
def mock_response(type = :success)
|
21
|
+
klass = case type
|
22
|
+
when :success then Net::HTTPSuccess
|
23
|
+
when :redirect then Net::HTTPRedirection
|
24
|
+
when :fail then Net::HTTPClientError
|
25
|
+
else type
|
26
|
+
end
|
27
|
+
|
28
|
+
klass.new(nil, nil, nil)
|
29
|
+
end
|
30
|
+
|
31
|
+
def mock_connection(ssl = true)
|
32
|
+
connection = mock('HTTP connection')
|
33
|
+
connection.stubs(:start)
|
34
|
+
connection.stubs(:finish)
|
35
|
+
if ssl
|
36
|
+
connection.expects(:use_ssl=).with(true)
|
37
|
+
connection.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
|
38
|
+
end
|
39
|
+
connection
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
Spec::Runner.configure do |config|
|
44
|
+
config.include SampleFeeds, HttpMocks
|
45
|
+
# config.predicate_matchers[:swim] = :can_swim?
|
46
|
+
|
47
|
+
config.mock_with :mocha
|
48
|
+
end
|
49
|
+
|
50
|
+
module Mocha
|
51
|
+
module ParameterMatchers
|
52
|
+
def query_string(entries, partial = false)
|
53
|
+
QueryStringMatcher.new(entries, partial)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class QueryStringMatcher < Mocha::ParameterMatchers::Base
|
59
|
+
|
60
|
+
def initialize(entries, partial)
|
61
|
+
@entries = entries
|
62
|
+
@partial = partial
|
63
|
+
end
|
64
|
+
|
65
|
+
def matches?(available_parameters)
|
66
|
+
string = available_parameters.shift.split('?').last
|
67
|
+
broken = string.split('&').map { |pair| pair.split('=').map { |value| CGI.unescape(value) } }
|
68
|
+
hash = Hash[*broken.flatten]
|
69
|
+
|
70
|
+
if @partial
|
71
|
+
has_entry_matchers = @entries.map do |key, value|
|
72
|
+
Mocha::ParameterMatchers::HasEntry.new(key, value)
|
73
|
+
end
|
74
|
+
Mocha::ParameterMatchers::AllOf.new(*has_entry_matchers).matches?([hash])
|
75
|
+
else
|
76
|
+
@entries == hash
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def mocha_inspect
|
81
|
+
"query_string(#{@entries.mocha_inspect})"
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'contacts/windows_live'
|
3
|
+
|
4
|
+
describe Contacts::WindowsLive do
|
5
|
+
|
6
|
+
before(:each) do
|
7
|
+
@path = Dir.getwd + '/spec/feeds/'
|
8
|
+
@wl = Contacts::WindowsLive.new(@path + 'contacts.yml')
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'parse the XML contacts document' do
|
12
|
+
contacts = Contacts::WindowsLive.parse_xml(contacts_xml)
|
13
|
+
|
14
|
+
contacts[0].name.should be_nil
|
15
|
+
contacts[0].email.should == 'froz@gmail.com'
|
16
|
+
contacts[1].name.should == 'Rafael Timbo'
|
17
|
+
contacts[1].email.should == 'timbo@hotmail.com'
|
18
|
+
contacts[2].name.should be_nil
|
19
|
+
contacts[2].email.should == 'betinho@hotmail.com'
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'should can be initialized by a YAML file' do
|
24
|
+
wll = @wl.instance_variable_get('@wll')
|
25
|
+
|
26
|
+
wll.appid.should == 'your_app_id'
|
27
|
+
wll.securityalgorithm.should == 'wsignin1.0'
|
28
|
+
wll.returnurl.should == 'http://yourserver.com/your_return_url'
|
29
|
+
end
|
30
|
+
|
31
|
+
def contacts_xml
|
32
|
+
File.open(@path + 'wl_contacts.xml', 'r+').read
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'contacts/yahoo'
|
3
|
+
|
4
|
+
describe Contacts::Yahoo do
|
5
|
+
|
6
|
+
before(:each) do
|
7
|
+
@path = Dir.getwd + '/spec/feeds/'
|
8
|
+
@yahoo = Contacts::Yahoo.new(@path + 'contacts.yml')
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'should generate an athentication URL' do
|
12
|
+
auth_url = @yahoo.get_authentication_url()
|
13
|
+
auth_url.should match(/https:\/\/api.login.yahoo.com\/WSLogin\/V1\/wslogin\?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&ts=.*&sig=.*/)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should have a simple interface to grab the contacts' do
|
17
|
+
@yahoo.expects(:access_user_credentials).returns(read_file('yh_credential.xml'))
|
18
|
+
@yahoo.expects(:access_address_book_api).returns(read_file('yh_contacts.txt'))
|
19
|
+
|
20
|
+
redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
|
21
|
+
|
22
|
+
results = @yahoo.contacts(redirect_path)
|
23
|
+
results.should have_contact('Hugo Barauna', 'hugo.barauna@gmail.com')
|
24
|
+
results.should have_contact('Nina Benchimol', 'nina@hotmail.com')
|
25
|
+
results.should have_contact('Andrea Dimitri', 'and@yahoo.com')
|
26
|
+
results.should have_contact('Ricardo Fiorelli', 'ricardo@poli.usp.br')
|
27
|
+
results.should have_contact('Priscila', 'pizinha@yahoo.com.br')
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'should validate yahoo redirect signature' do
|
31
|
+
redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
|
32
|
+
|
33
|
+
@yahoo.validate_signature(redirect_path).should be_true
|
34
|
+
@yahoo.token.should == 'AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-'
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'should detect when the redirect is not valid' do
|
38
|
+
redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=de4fe4ebd50a8075f75dcc23f6aca04f'
|
39
|
+
|
40
|
+
lambda{ @yahoo.validate_signature(redirect_path) }.should raise_error
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'should generate the credential request URL' do
|
44
|
+
redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
|
45
|
+
@yahoo.validate_signature(redirect_path)
|
46
|
+
|
47
|
+
@yahoo.get_credential_url.should match(/https:\/\/api.login.yahoo.com\/WSLogin\/V1\/wspwtoken_login\?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&ts=.*&token=.*&sig=.*/)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'should parse the credential XML' do
|
51
|
+
@yahoo.parse_credentials(read_file('yh_credential.xml'))
|
52
|
+
|
53
|
+
@yahoo.wssid.should == 'tr.jZsW/ulc'
|
54
|
+
@yahoo.cookie.should == 'Y=cdunlEx76ZEeIdWyeJNOegxfy.jkeoULJCnc7Q0Vr8D5P.u.EE2vCa7G2MwBoULuZhvDZuJNqhHwF3v5RJ4dnsWsEDGOjYV1k6snoln3RlQmx0Ggxs0zAYgbaA4BFQk5ieAkpipq19l6GoD_k8IqXRfJN0Q54BbekC_O6Tj3zl2wV3YQK6Mi2MWBQFSBsO26Tw_1yMAF8saflF9EX1fQl4N.1yBr8UXb6LLDiPQmlISq1_c6S6rFbaOhSZMgO78f2iqZmUAk9RmCHrqPJiHEo.mJlxxHaQsuqTMf7rwLEHqK__Gi_bLypGtaslqeWyS0h2J.B5xwRC8snfEs3ct_kLXT3ngP_pK3MeMf2pe1TiJ4JXVciY9br.KJFUgNd4J6rmQsSFj4wPLoMGCETfVc.M8KLiaFHasZqXDyCE7tvd1khAjQ_xLfQKlg1GlBOWmbimQ1FhdHnsVj3svXjEGquRh8JI2sHIQrzoiqAPBf9WFKQcH0t_1dxf4MOH.7gJaYDPEozCW5EcCsYjuHup9xJKxyTddh5pk8yUg5bURzA.TwPalExMKsbv.RWFBhzWKuTp5guNcqjmUHcCoT19_qFENHX41Xf3texAnsDDGj'
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'should parse the contacts json response' do
|
58
|
+
json = read_file('yh_contacts.txt')
|
59
|
+
|
60
|
+
Contacts::Yahoo.parse_contacts(json).should have_contact('Hugo Barauna', 'hugo.barauna@gmail.com')
|
61
|
+
Contacts::Yahoo.parse_contacts(json).should have_contact('Nina Benchimol', 'nina@hotmail.com')
|
62
|
+
Contacts::Yahoo.parse_contacts(json).should have_contact('Andrea Dimitri', 'and@yahoo.com')
|
63
|
+
Contacts::Yahoo.parse_contacts(json).should have_contact('Ricardo Fiorelli', 'ricardo@poli.usp.br')
|
64
|
+
Contacts::Yahoo.parse_contacts(json).should have_contact('Priscila', 'pizinha@yahoo.com.br')
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'should can be initialized by a YAML file' do
|
68
|
+
@yahoo.appid.should == 'i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--'
|
69
|
+
@yahoo.secret.should == 'a34f389cbd135de4618eed5e23409d34450'
|
70
|
+
end
|
71
|
+
|
72
|
+
def read_file(file)
|
73
|
+
File.open(@path + file, 'r+').read
|
74
|
+
end
|
75
|
+
|
76
|
+
def have_contact(name, email)
|
77
|
+
matcher_class = Class.new()
|
78
|
+
matcher_class.instance_eval do
|
79
|
+
define_method(:matches?) {|some_contacts| some_contacts.any? {|a_contact| a_contact.name == name && a_contact.emails.include?(email)}}
|
80
|
+
end
|
81
|
+
matcher_class.new
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{sundawg_contacts}
|
5
|
+
s.version = "0.0.1"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["Christopher Sun"]
|
9
|
+
s.date = %q{2010-04-13}
|
10
|
+
s.description = %q{Project fork of Mislav Contacts to support signed GData protocol.}
|
11
|
+
s.email = %q{christopher.sun@gmail.com}
|
12
|
+
s.extra_rdoc_files = ["LICENSE", "README.rdoc", "lib/config/contacts.yml", "lib/contacts.rb", "lib/contacts/flickr.rb", "lib/contacts/google.rb", "lib/contacts/version.rb", "lib/contacts/windows_live.rb", "lib/contacts/yahoo.rb"]
|
13
|
+
s.files = ["LICENSE", "MIT-LICENSE", "Manifest", "README.rdoc", "Rakefile", "lib/config/contacts.yml", "lib/contacts.rb", "lib/contacts/flickr.rb", "lib/contacts/google.rb", "lib/contacts/version.rb", "lib/contacts/windows_live.rb", "lib/contacts/yahoo.rb", "spec/contact_spec.rb", "spec/feeds/contacts.yml", "spec/feeds/flickr/auth.getFrob.xml", "spec/feeds/flickr/auth.getToken.xml", "spec/feeds/google-many.xml", "spec/feeds/google-single.xml", "spec/feeds/wl_contacts.xml", "spec/feeds/yh_contacts.txt", "spec/feeds/yh_credential.xml", "spec/flickr/auth_spec.rb", "spec/gmail/auth_spec.rb", "spec/gmail/fetching_spec.rb", "spec/rcov.opts", "spec/spec.opts", "spec/spec_helper.rb", "spec/windows_live/windows_live_spec.rb", "spec/yahoo/yahoo_spec.rb", "vendor/windows_live_login.rb", "sundawg_contacts.gemspec"]
|
14
|
+
s.homepage = %q{http://github.com/SunDawg/contacts}
|
15
|
+
s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Sundawg_contacts", "--main", "README.rdoc"]
|
16
|
+
s.require_paths = ["lib"]
|
17
|
+
s.rubyforge_project = %q{sundawg_contacts}
|
18
|
+
s.rubygems_version = %q{1.3.6}
|
19
|
+
s.summary = %q{Project fork of Mislav Contacts to support signed GData protocol.}
|
20
|
+
|
21
|
+
if s.respond_to? :specification_version then
|
22
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
23
|
+
s.specification_version = 3
|
24
|
+
|
25
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
26
|
+
s.add_development_dependency(%q<fakeweb>, [">= 1.2.7"])
|
27
|
+
else
|
28
|
+
s.add_dependency(%q<fakeweb>, [">= 1.2.7"])
|
29
|
+
end
|
30
|
+
else
|
31
|
+
s.add_dependency(%q<fakeweb>, [">= 1.2.7"])
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,1156 @@
|
|
1
|
+
#######################################################################
|
2
|
+
# This SDK is provide by Microsoft. I just use it for the delegated
|
3
|
+
# authentication part. You can find it in http://www.microsoft.com/downloads/details.aspx?FamilyId=24195B4E-6335-4844-A71D-7D395D20E67B&displaylang=en
|
4
|
+
#
|
5
|
+
# Author: Hugo Baraúna (hugo.barauna@gmail.com)
|
6
|
+
#######################################################################
|
7
|
+
|
8
|
+
|
9
|
+
#######################################################################
|
10
|
+
#######################################################################
|
11
|
+
# FILE: windowslivelogin.rb
|
12
|
+
#
|
13
|
+
# DESCRIPTION: Sample implementation of Web Authentication and
|
14
|
+
# Delegated Authentication protocol in Ruby. Also
|
15
|
+
# includes trusted sign-in and application verification
|
16
|
+
# sample implementations.
|
17
|
+
#
|
18
|
+
# VERSION: 1.1
|
19
|
+
#
|
20
|
+
# Copyright (c) 2008 Microsoft Corporation. All Rights Reserved.
|
21
|
+
#######################################################################
|
22
|
+
|
23
|
+
require 'cgi'
|
24
|
+
require 'uri'
|
25
|
+
require 'base64'
|
26
|
+
require 'digest/sha2'
|
27
|
+
require 'hmac-sha2'
|
28
|
+
require 'net/https'
|
29
|
+
require 'rexml/document'
|
30
|
+
|
31
|
+
class WindowsLiveLogin
|
32
|
+
# Raised when user denies permission and windows live returns with a
|
33
|
+
# post body having action=cancel (instead of action=delauth)
|
34
|
+
class PermissionDenied < StandardError; end
|
35
|
+
|
36
|
+
#####################################################################
|
37
|
+
# Stub implementation for logging errors. If you want to enable
|
38
|
+
# debugging output using the default mechanism, specify true.
|
39
|
+
# By default, debug information will be printed to the standard
|
40
|
+
# error output and should be visible in the web server logs.
|
41
|
+
#####################################################################
|
42
|
+
def setDebug(flag)
|
43
|
+
@debug = flag
|
44
|
+
end
|
45
|
+
|
46
|
+
#####################################################################
|
47
|
+
# Stub implementation for logging errors. By default, this function
|
48
|
+
# does nothing if the debug flag has not been set with setDebug.
|
49
|
+
# Otherwise, it tries to log the error message.
|
50
|
+
#####################################################################
|
51
|
+
def debug(error)
|
52
|
+
return unless @debug
|
53
|
+
return if error.nil? or error.empty?
|
54
|
+
warn("Windows Live ID Authentication SDK #{error}")
|
55
|
+
nil
|
56
|
+
end
|
57
|
+
|
58
|
+
#####################################################################
|
59
|
+
# Stub implementation for handling a fatal error.
|
60
|
+
#####################################################################
|
61
|
+
def fatal(error)
|
62
|
+
debug(error)
|
63
|
+
raise(error)
|
64
|
+
end
|
65
|
+
|
66
|
+
#####################################################################
|
67
|
+
# Initialize the WindowsLiveLogin module with the application ID,
|
68
|
+
# secret key, and security algorithm.
|
69
|
+
#
|
70
|
+
# We recommend that you employ strong measures to protect the
|
71
|
+
# secret key. The secret key should never be exposed to the Web
|
72
|
+
# or other users.
|
73
|
+
#
|
74
|
+
# Be aware that if you do not supply these settings at
|
75
|
+
# initialization time, you may need to set the corresponding
|
76
|
+
# properties manually.
|
77
|
+
#
|
78
|
+
# For Delegated Authentication, you may optionally specify the
|
79
|
+
# privacy policy URL and return URL. If you do not specify these
|
80
|
+
# values here, the default values that you specified when you
|
81
|
+
# registered your application will be used.
|
82
|
+
#
|
83
|
+
# The 'force_delauth_nonprovisioned' flag also indicates whether
|
84
|
+
# your application is registered for Delegated Authentication
|
85
|
+
# (that is, whether it uses an application ID and secret key). We
|
86
|
+
# recommend that your Delegated Authentication application always
|
87
|
+
# be registered for enhanced security and functionality.
|
88
|
+
#####################################################################
|
89
|
+
def initialize(appid=nil, secret=nil, securityalgorithm=nil,
|
90
|
+
force_delauth_nonprovisioned=nil,
|
91
|
+
policyurl=nil, returnurl=nil)
|
92
|
+
self.force_delauth_nonprovisioned = force_delauth_nonprovisioned
|
93
|
+
self.appid = appid if appid
|
94
|
+
self.secret = secret if secret
|
95
|
+
self.securityalgorithm = securityalgorithm if securityalgorithm
|
96
|
+
self.policyurl = policyurl if policyurl
|
97
|
+
self.returnurl = returnurl if returnurl
|
98
|
+
end
|
99
|
+
|
100
|
+
#####################################################################
|
101
|
+
# Initialize the WindowsLiveLogin module from a settings file.
|
102
|
+
#
|
103
|
+
# 'settingsFile' specifies the location of the XML settings file
|
104
|
+
# that contains the application ID, secret key, and security
|
105
|
+
# algorithm. The file is of the following format:
|
106
|
+
#
|
107
|
+
# <windowslivelogin>
|
108
|
+
# <appid>APPID</appid>
|
109
|
+
# <secret>SECRET</secret>
|
110
|
+
# <securityalgorithm>wsignin1.0</securityalgorithm>
|
111
|
+
# </windowslivelogin>
|
112
|
+
#
|
113
|
+
# In a Delegated Authentication scenario, you may also specify
|
114
|
+
# 'returnurl' and 'policyurl' in the settings file, as shown in the
|
115
|
+
# Delegated Authentication samples.
|
116
|
+
#
|
117
|
+
# We recommend that you store the WindowsLiveLogin settings file
|
118
|
+
# in an area on your server that cannot be accessed through the
|
119
|
+
# Internet. This file contains important confidential information.
|
120
|
+
#####################################################################
|
121
|
+
def self.initFromXml(settingsFile)
|
122
|
+
o = self.new
|
123
|
+
settings = o.parseSettings(settingsFile)
|
124
|
+
|
125
|
+
o.setDebug(settings['debug'] == 'true')
|
126
|
+
o.force_delauth_nonprovisioned =
|
127
|
+
(settings['force_delauth_nonprovisioned'] == 'true')
|
128
|
+
|
129
|
+
o.appid = settings['appid']
|
130
|
+
o.secret = settings['secret']
|
131
|
+
o.oldsecret = settings['oldsecret']
|
132
|
+
o.oldsecretexpiry = settings['oldsecretexpiry']
|
133
|
+
o.securityalgorithm = settings['securityalgorithm']
|
134
|
+
o.policyurl = settings['policyurl']
|
135
|
+
o.returnurl = settings['returnurl']
|
136
|
+
o.baseurl = settings['baseurl']
|
137
|
+
o.secureurl = settings['secureurl']
|
138
|
+
o.consenturl = settings['consenturl']
|
139
|
+
o
|
140
|
+
end
|
141
|
+
|
142
|
+
#####################################################################
|
143
|
+
# Sets the application ID. Use this method if you did not specify
|
144
|
+
# an application ID at initialization.
|
145
|
+
#####################################################################
|
146
|
+
def appid=(appid)
|
147
|
+
if (appid.nil? or appid.empty?)
|
148
|
+
return if force_delauth_nonprovisioned
|
149
|
+
fatal("Error: appid: Null application ID.")
|
150
|
+
end
|
151
|
+
if (not appid =~ /^\w+$/)
|
152
|
+
fatal("Error: appid: Application ID must be alpha-numeric: " + appid)
|
153
|
+
end
|
154
|
+
@appid = appid
|
155
|
+
end
|
156
|
+
|
157
|
+
#####################################################################
|
158
|
+
# Returns the application ID.
|
159
|
+
#####################################################################
|
160
|
+
def appid
|
161
|
+
if (@appid.nil? or @appid.empty?)
|
162
|
+
fatal("Error: appid: App ID was not set. Aborting.")
|
163
|
+
end
|
164
|
+
@appid
|
165
|
+
end
|
166
|
+
|
167
|
+
#####################################################################
|
168
|
+
# Sets your secret key. Use this method if you did not specify
|
169
|
+
# a secret key at initialization.
|
170
|
+
#####################################################################
|
171
|
+
def secret=(secret)
|
172
|
+
if (secret.nil? or secret.empty?)
|
173
|
+
return if force_delauth_nonprovisioned
|
174
|
+
fatal("Error: secret=: Secret must be non-null.")
|
175
|
+
end
|
176
|
+
if (secret.size < 16)
|
177
|
+
fatal("Error: secret=: Secret must be at least 16 characters.")
|
178
|
+
end
|
179
|
+
@signkey = derive(secret, "SIGNATURE")
|
180
|
+
@cryptkey = derive(secret, "ENCRYPTION")
|
181
|
+
end
|
182
|
+
|
183
|
+
#####################################################################
|
184
|
+
# Sets your old secret key.
|
185
|
+
#
|
186
|
+
# Use this property to set your old secret key if you are in the
|
187
|
+
# process of transitioning to a new secret key. You may need this
|
188
|
+
# property because the Windows Live ID servers can take up to
|
189
|
+
# 24 hours to propagate a new secret key after you have updated
|
190
|
+
# your application settings.
|
191
|
+
#
|
192
|
+
# If an old secret key is specified here and has not expired
|
193
|
+
# (as determined by the oldsecretexpiry setting), it will be used
|
194
|
+
# as a fallback if token decryption fails with the new secret
|
195
|
+
# key.
|
196
|
+
#####################################################################
|
197
|
+
def oldsecret=(secret)
|
198
|
+
return if (secret.nil? or secret.empty?)
|
199
|
+
if (secret.size < 16)
|
200
|
+
fatal("Error: oldsecret=: Secret must be at least 16 characters.")
|
201
|
+
end
|
202
|
+
@oldsignkey = derive(secret, "SIGNATURE")
|
203
|
+
@oldcryptkey = derive(secret, "ENCRYPTION")
|
204
|
+
end
|
205
|
+
|
206
|
+
#####################################################################
|
207
|
+
# Sets the expiry time for your old secret key.
|
208
|
+
#
|
209
|
+
# After this time has passed, the old secret key will no longer be
|
210
|
+
# used even if token decryption fails with the new secret key.
|
211
|
+
#
|
212
|
+
# The old secret expiry time is represented as the number of seconds
|
213
|
+
# elapsed since January 1, 1970.
|
214
|
+
#####################################################################
|
215
|
+
def oldsecretexpiry=(timestamp)
|
216
|
+
return if (timestamp.nil? or timestamp.empty?)
|
217
|
+
timestamp = timestamp.to_i
|
218
|
+
fatal("Error: oldsecretexpiry=: Invalid timestamp: #{timestamp}") if (timestamp <= 0)
|
219
|
+
@oldsecretexpiry = Time.at timestamp
|
220
|
+
end
|
221
|
+
|
222
|
+
#####################################################################
|
223
|
+
# Gets the old secret key expiry time.
|
224
|
+
#####################################################################
|
225
|
+
attr_accessor :oldsecretexpiry
|
226
|
+
|
227
|
+
#####################################################################
|
228
|
+
# Sets or gets the version of the security algorithm being used.
|
229
|
+
#####################################################################
|
230
|
+
attr_accessor :securityalgorithm
|
231
|
+
|
232
|
+
def securityalgorithm
|
233
|
+
if(@securityalgorithm.nil? or @securityalgorithm.empty?)
|
234
|
+
"wsignin1.0"
|
235
|
+
else
|
236
|
+
@securityalgorithm
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
#####################################################################
|
241
|
+
# Sets a flag that indicates whether Delegated Authentication
|
242
|
+
# is non-provisioned (i.e. does not use an application ID or secret
|
243
|
+
# key).
|
244
|
+
#####################################################################
|
245
|
+
attr_accessor :force_delauth_nonprovisioned
|
246
|
+
|
247
|
+
#####################################################################
|
248
|
+
# Sets the privacy policy URL, to which the Windows Live ID consent
|
249
|
+
# service redirects users to view the privacy policy of your Web
|
250
|
+
# site for Delegated Authentication.
|
251
|
+
#####################################################################
|
252
|
+
def policyurl=(policyurl)
|
253
|
+
if ((policyurl.nil? or policyurl.empty?) and force_delauth_nonprovisioned)
|
254
|
+
fatal("Error: policyurl=: Invalid policy URL specified.")
|
255
|
+
end
|
256
|
+
@policyurl = policyurl
|
257
|
+
end
|
258
|
+
|
259
|
+
#####################################################################
|
260
|
+
# Gets the privacy policy URL for your site.
|
261
|
+
#####################################################################
|
262
|
+
def policyurl
|
263
|
+
if (@policyurl.nil? or @policyurl.empty?)
|
264
|
+
debug("Warning: In the initial release of Del Auth, a Policy URL must be configured in the SDK for both provisioned and non-provisioned scenarios.")
|
265
|
+
raise("Error: policyurl: Policy URL must be set in a Del Auth non-provisioned scenario. Aborting.") if force_delauth_nonprovisioned
|
266
|
+
end
|
267
|
+
@policyurl
|
268
|
+
end
|
269
|
+
|
270
|
+
#####################################################################
|
271
|
+
# Sets the return URL--the URL on your site to which the consent
|
272
|
+
# service redirects users (along with the action, consent token,
|
273
|
+
# and application context) after they have successfully provided
|
274
|
+
# consent information for Delegated Authentication. This value will
|
275
|
+
# override the return URL specified during registration.
|
276
|
+
#####################################################################
|
277
|
+
def returnurl=(returnurl)
|
278
|
+
if ((returnurl.nil? or returnurl.empty?) and force_delauth_nonprovisioned)
|
279
|
+
fatal("Error: returnurl=: Invalid return URL specified.")
|
280
|
+
end
|
281
|
+
@returnurl = returnurl
|
282
|
+
end
|
283
|
+
|
284
|
+
|
285
|
+
#####################################################################
|
286
|
+
# Returns the return URL of your site.
|
287
|
+
#####################################################################
|
288
|
+
def returnurl
|
289
|
+
if ((@returnurl.nil? or @returnurl.empty?) and force_delauth_nonprovisioned)
|
290
|
+
fatal("Error: returnurl: Return URL must be set in a Del Auth non-provisioned scenario. Aborting.")
|
291
|
+
end
|
292
|
+
@returnurl
|
293
|
+
end
|
294
|
+
|
295
|
+
#####################################################################
|
296
|
+
# Sets or gets the base URL to use for the Windows Live Login server. You
|
297
|
+
# should not have to change this property. Furthermore, we recommend
|
298
|
+
# that you use the Sign In control instead of the URL methods
|
299
|
+
# provided here.
|
300
|
+
#####################################################################
|
301
|
+
attr_accessor :baseurl
|
302
|
+
|
303
|
+
def baseurl
|
304
|
+
if(@baseurl.nil? or @baseurl.empty?)
|
305
|
+
"http://login.live.com/"
|
306
|
+
else
|
307
|
+
@baseurl
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
#####################################################################
|
312
|
+
# Sets or gets the secure (HTTPS) URL to use for the Windows Live Login
|
313
|
+
# server. You should not have to change this property.
|
314
|
+
#####################################################################
|
315
|
+
attr_accessor :secureurl
|
316
|
+
|
317
|
+
def secureurl
|
318
|
+
if(@secureurl.nil? or @secureurl.empty?)
|
319
|
+
"https://login.live.com/"
|
320
|
+
else
|
321
|
+
@secureurl
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
#####################################################################
|
326
|
+
# Sets or gets the Consent Base URL to use for the Windows Live Consent
|
327
|
+
# server. You should not have to use or change this property directly.
|
328
|
+
#####################################################################
|
329
|
+
attr_accessor :consenturl
|
330
|
+
|
331
|
+
def consenturl
|
332
|
+
if(@consenturl.nil? or @consenturl.empty?)
|
333
|
+
"https://consent.live.com/"
|
334
|
+
else
|
335
|
+
@consenturl
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
#######################################################################
|
341
|
+
# Implementation of the basic methods needed for Web Authentication.
|
342
|
+
#######################################################################
|
343
|
+
class WindowsLiveLogin
|
344
|
+
#####################################################################
|
345
|
+
# Returns the sign-in URL to use for the Windows Live Login server.
|
346
|
+
# We recommend that you use the Sign In control instead.
|
347
|
+
#
|
348
|
+
# If you specify it, 'context' will be returned as-is in the sign-in
|
349
|
+
# response for site-specific use.
|
350
|
+
#####################################################################
|
351
|
+
def getLoginUrl(context=nil, market=nil)
|
352
|
+
url = baseurl + "wlogin.srf?appid=#{appid}"
|
353
|
+
url += "&alg=#{securityalgorithm}"
|
354
|
+
url += "&appctx=#{CGI.escape(context)}" if context
|
355
|
+
url += "&mkt=#{CGI.escape(market)}" if market
|
356
|
+
url
|
357
|
+
end
|
358
|
+
|
359
|
+
#####################################################################
|
360
|
+
# Returns the sign-out URL to use for the Windows Live Login server.
|
361
|
+
# We recommend that you use the Sign In control instead.
|
362
|
+
#####################################################################
|
363
|
+
def getLogoutUrl(market=nil)
|
364
|
+
url = baseurl + "logout.srf?appid=#{appid}"
|
365
|
+
url += "&mkt=#{CGI.escape(market)}" if market
|
366
|
+
url
|
367
|
+
end
|
368
|
+
|
369
|
+
#####################################################################
|
370
|
+
# Holds the user information after a successful sign-in.
|
371
|
+
#
|
372
|
+
# 'timestamp' is the time as obtained from the SSO token.
|
373
|
+
# 'id' is the pairwise unique ID for the user.
|
374
|
+
# 'context' is the application context that was originally passed to
|
375
|
+
# the sign-in request, if any.
|
376
|
+
# 'token' is the encrypted Web Authentication token that contains the
|
377
|
+
# UID. This can be cached in a cookie and the UID can be retrieved by
|
378
|
+
# calling the processToken method.
|
379
|
+
# 'usePersistentCookie?' indicates whether the application is
|
380
|
+
# expected to store the user token in a session or persistent
|
381
|
+
# cookie.
|
382
|
+
#####################################################################
|
383
|
+
class User
|
384
|
+
attr_reader :timestamp, :id, :context, :token
|
385
|
+
|
386
|
+
def usePersistentCookie?
|
387
|
+
@usePersistentCookie
|
388
|
+
end
|
389
|
+
|
390
|
+
|
391
|
+
#####################################################################
|
392
|
+
# Initialize the User with time stamp, userid, flags, context and token.
|
393
|
+
#####################################################################
|
394
|
+
def initialize(timestamp, id, flags, context, token)
|
395
|
+
self.timestamp = timestamp
|
396
|
+
self.id = id
|
397
|
+
self.flags = flags
|
398
|
+
self.context = context
|
399
|
+
self.token = token
|
400
|
+
end
|
401
|
+
|
402
|
+
private
|
403
|
+
attr_writer :timestamp, :id, :flags, :context, :token
|
404
|
+
|
405
|
+
#####################################################################
|
406
|
+
# Sets or gets the Unix timestamp as obtained from the SSO token.
|
407
|
+
#####################################################################
|
408
|
+
def timestamp=(timestamp)
|
409
|
+
raise("Error: User: Null timestamp in token.") unless timestamp
|
410
|
+
timestamp = timestamp.to_i
|
411
|
+
raise("Error: User: Invalid timestamp: #{timestamp}") if (timestamp <= 0)
|
412
|
+
@timestamp = Time.at timestamp
|
413
|
+
end
|
414
|
+
|
415
|
+
#####################################################################
|
416
|
+
# Sets or gets the pairwise unique ID for the user.
|
417
|
+
#####################################################################
|
418
|
+
def id=(id)
|
419
|
+
raise("Error: User: Null id in token.") unless id
|
420
|
+
raise("Error: User: Invalid id: #{id}") unless (id =~ /^\w+$/)
|
421
|
+
@id = id
|
422
|
+
end
|
423
|
+
|
424
|
+
#####################################################################
|
425
|
+
# Sets or gets the usePersistentCookie flag for the user.
|
426
|
+
#####################################################################
|
427
|
+
def flags=(flags)
|
428
|
+
@usePersistentCookie = false
|
429
|
+
if flags
|
430
|
+
@usePersistentCookie = ((flags.to_i % 2) == 1)
|
431
|
+
end
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
#####################################################################
|
436
|
+
# Processes the sign-in response from the Windows Live sign-in server.
|
437
|
+
#
|
438
|
+
# 'query' contains the preprocessed POST table, such as that
|
439
|
+
# returned by CGI.params or Rails. (The unprocessed POST string
|
440
|
+
# could also be used here but we do not recommend it).
|
441
|
+
#
|
442
|
+
# This method returns a User object on successful sign-in; otherwise
|
443
|
+
# it returns nil.
|
444
|
+
#####################################################################
|
445
|
+
def processLogin(query)
|
446
|
+
query = parse query
|
447
|
+
unless query
|
448
|
+
debug("Error: processLogin: Failed to parse query.")
|
449
|
+
return
|
450
|
+
end
|
451
|
+
action = query['action']
|
452
|
+
unless action == 'login'
|
453
|
+
debug("Warning: processLogin: query action ignored: #{action}.")
|
454
|
+
return
|
455
|
+
end
|
456
|
+
token = query['stoken']
|
457
|
+
context = CGI.unescape(query['appctx']) if query['appctx']
|
458
|
+
processToken(token, context)
|
459
|
+
end
|
460
|
+
|
461
|
+
#####################################################################
|
462
|
+
# Decodes and validates a Web Authentication token. Returns a User
|
463
|
+
# object on success. If a context is passed in, it will be returned
|
464
|
+
# as the context field in the User object.
|
465
|
+
#####################################################################
|
466
|
+
def processToken(token, context=nil)
|
467
|
+
if token.nil? or token.empty?
|
468
|
+
debug("Error: processToken: Null/empty token.")
|
469
|
+
return
|
470
|
+
end
|
471
|
+
stoken = decodeAndValidateToken token
|
472
|
+
stoken = parse stoken
|
473
|
+
unless stoken
|
474
|
+
debug("Error: processToken: Failed to decode/validate token: #{token}")
|
475
|
+
return
|
476
|
+
end
|
477
|
+
sappid = stoken['appid']
|
478
|
+
unless sappid == appid
|
479
|
+
debug("Error: processToken: Application ID in token did not match ours: #{sappid}, #{appid}")
|
480
|
+
return
|
481
|
+
end
|
482
|
+
begin
|
483
|
+
user = User.new(stoken['ts'], stoken['uid'], stoken['flags'],
|
484
|
+
context, token)
|
485
|
+
return user
|
486
|
+
rescue Exception => e
|
487
|
+
debug("Error: processToken: Contents of token considered invalid: #{e}")
|
488
|
+
return
|
489
|
+
end
|
490
|
+
end
|
491
|
+
|
492
|
+
#####################################################################
|
493
|
+
# Returns an appropriate content type and body response that the
|
494
|
+
# application handler can return to signify a successful sign-out
|
495
|
+
# from the application.
|
496
|
+
#
|
497
|
+
# When a user signs out of Windows Live or a Windows Live
|
498
|
+
# application, a best-effort attempt is made at signing the user out
|
499
|
+
# from all other Windows Live applications the user might be signed
|
500
|
+
# in to. This is done by calling the handler page for each
|
501
|
+
# application with 'action' set to 'clearcookie' in the query
|
502
|
+
# string. The application handler is then responsible for clearing
|
503
|
+
# any cookies or data associated with the sign-in. After successfully
|
504
|
+
# signing the user out, the handler should return a GIF (any GIF)
|
505
|
+
# image as response to the 'action=clearcookie' query.
|
506
|
+
#####################################################################
|
507
|
+
def getClearCookieResponse()
|
508
|
+
type = "image/gif"
|
509
|
+
content = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAIBTAA7"
|
510
|
+
content = Base64.decode64(content)
|
511
|
+
return type, content
|
512
|
+
end
|
513
|
+
end
|
514
|
+
|
515
|
+
#######################################################################
|
516
|
+
# Implementation of the basic methods needed for Delegated
|
517
|
+
# Authentication.
|
518
|
+
#######################################################################
|
519
|
+
class WindowsLiveLogin
|
520
|
+
#####################################################################
|
521
|
+
# Returns the consent URL to use for Delegated Authentication for
|
522
|
+
# the given comma-delimited list of offers.
|
523
|
+
#
|
524
|
+
# If you specify it, 'context' will be returned as-is in the consent
|
525
|
+
# response for site-specific use.
|
526
|
+
#
|
527
|
+
# The registered/configured return URL can also be overridden by
|
528
|
+
# specifying 'ru' here.
|
529
|
+
#
|
530
|
+
# You can change the language in which the consent page is displayed
|
531
|
+
# by specifying a culture ID (For example, 'fr-fr' or 'en-us') in the
|
532
|
+
# 'market' parameter.
|
533
|
+
#####################################################################
|
534
|
+
def getConsentUrl(offers, context=nil, ru=nil, market=nil)
|
535
|
+
if (offers.nil? or offers.empty?)
|
536
|
+
fatal("Error: getConsentUrl: Invalid offers list.")
|
537
|
+
end
|
538
|
+
url = consenturl + "Delegation.aspx?ps=#{CGI.escape(offers)}"
|
539
|
+
url += "&appctx=#{CGI.escape(context)}" if context
|
540
|
+
ru = returnurl if (ru.nil? or ru.empty?)
|
541
|
+
url += "&ru=#{CGI.escape(ru)}" if ru
|
542
|
+
pu = policyurl
|
543
|
+
url += "&pl=#{CGI.escape(pu)}" if pu
|
544
|
+
url += "&mkt=#{CGI.escape(market)}" if market
|
545
|
+
url += "&app=#{getAppVerifier()}" unless force_delauth_nonprovisioned
|
546
|
+
url
|
547
|
+
end
|
548
|
+
|
549
|
+
#####################################################################
|
550
|
+
# Returns the URL to use to download a new consent token, given the
|
551
|
+
# offers and refresh token.
|
552
|
+
# The registered/configured return URL can also be overridden by
|
553
|
+
# specifying 'ru' here.
|
554
|
+
#####################################################################
|
555
|
+
def getRefreshConsentTokenUrl(offers, refreshtoken, ru)
|
556
|
+
if (offers.nil? or offers.empty?)
|
557
|
+
fatal("Error: getRefreshConsentTokenUrl: Invalid offers list.")
|
558
|
+
end
|
559
|
+
if (refreshtoken.nil? or refreshtoken.empty?)
|
560
|
+
fatal("Error: getRefreshConsentTokenUrl: Invalid refresh token.")
|
561
|
+
end
|
562
|
+
url = consenturl + "RefreshToken.aspx?ps=#{CGI.escape(offers)}"
|
563
|
+
url += "&reft=#{refreshtoken}"
|
564
|
+
ru = returnurl if (ru.nil? or ru.empty?)
|
565
|
+
url += "&ru=#{CGI.escape(ru)}" if ru
|
566
|
+
url += "&app=#{getAppVerifier()}" unless force_delauth_nonprovisioned
|
567
|
+
url
|
568
|
+
end
|
569
|
+
|
570
|
+
#####################################################################
|
571
|
+
# Returns the URL for the consent-management user interface.
|
572
|
+
# You can change the language in which the consent page is displayed
|
573
|
+
# by specifying a culture ID (For example, 'fr-fr' or 'en-us') in the
|
574
|
+
# 'market' parameter.
|
575
|
+
#####################################################################
|
576
|
+
def getManageConsentUrl(market=nil)
|
577
|
+
url = consenturl + "ManageConsent.aspx"
|
578
|
+
url += "?mkt=#{CGI.escape(market)}" if market
|
579
|
+
url
|
580
|
+
end
|
581
|
+
|
582
|
+
class ConsentToken
|
583
|
+
attr_reader :delegationtoken, :refreshtoken, :sessionkey, :expiry
|
584
|
+
attr_reader :offers, :offers_string, :locationid, :context
|
585
|
+
attr_reader :decodedtoken, :token
|
586
|
+
|
587
|
+
#####################################################################
|
588
|
+
# Indicates whether the delegation token is set and has not expired.
|
589
|
+
#####################################################################
|
590
|
+
def isValid?
|
591
|
+
return false unless delegationtoken
|
592
|
+
return ((Time.now.to_i-300) < expiry.to_i)
|
593
|
+
end
|
594
|
+
|
595
|
+
#####################################################################
|
596
|
+
# Refreshes the current token and replace it. If operation succeeds
|
597
|
+
# true is returned to signify success.
|
598
|
+
#####################################################################
|
599
|
+
def refresh
|
600
|
+
ct = @wll.refreshConsentToken(self)
|
601
|
+
return false unless ct
|
602
|
+
copy(ct)
|
603
|
+
true
|
604
|
+
end
|
605
|
+
|
606
|
+
#####################################################################
|
607
|
+
# Initialize the ConsentToken module with the WindowsLiveLogin,
|
608
|
+
# delegation token, refresh token, session key, expiry, offers,
|
609
|
+
# location ID, context, decoded token, and raw token.
|
610
|
+
#####################################################################
|
611
|
+
def initialize(wll, delegationtoken, refreshtoken, sessionkey, expiry,
|
612
|
+
offers, locationid, context, decodedtoken, token)
|
613
|
+
@wll = wll
|
614
|
+
self.delegationtoken = delegationtoken
|
615
|
+
self.refreshtoken = refreshtoken
|
616
|
+
self.sessionkey = sessionkey
|
617
|
+
self.expiry = expiry
|
618
|
+
self.offers = offers
|
619
|
+
self.locationid = locationid
|
620
|
+
self.context = context
|
621
|
+
self.decodedtoken = decodedtoken
|
622
|
+
self.token = token
|
623
|
+
end
|
624
|
+
|
625
|
+
private
|
626
|
+
attr_writer :delegationtoken, :refreshtoken, :sessionkey, :expiry
|
627
|
+
attr_writer :offers, :offers_string, :locationid, :context
|
628
|
+
attr_writer :decodedtoken, :token, :locationid
|
629
|
+
|
630
|
+
#####################################################################
|
631
|
+
# Sets the delegation token.
|
632
|
+
#####################################################################
|
633
|
+
def delegationtoken=(delegationtoken)
|
634
|
+
if (delegationtoken.nil? or delegationtoken.empty?)
|
635
|
+
raise("Error: ConsentToken: Null delegation token.")
|
636
|
+
end
|
637
|
+
@delegationtoken = delegationtoken
|
638
|
+
end
|
639
|
+
|
640
|
+
#####################################################################
|
641
|
+
# Sets the session key.
|
642
|
+
#####################################################################
|
643
|
+
def sessionkey=(sessionkey)
|
644
|
+
if (sessionkey.nil? or sessionkey.empty?)
|
645
|
+
raise("Error: ConsentToken: Null session key.")
|
646
|
+
end
|
647
|
+
@sessionkey = @wll.u64(sessionkey)
|
648
|
+
end
|
649
|
+
|
650
|
+
#####################################################################
|
651
|
+
# Sets the expiry time of the delegation token.
|
652
|
+
#####################################################################
|
653
|
+
def expiry=(expiry)
|
654
|
+
if (expiry.nil? or expiry.empty?)
|
655
|
+
raise("Error: ConsentToken: Null expiry time.")
|
656
|
+
end
|
657
|
+
expiry = expiry.to_i
|
658
|
+
raise("Error: ConsentToken: Invalid expiry: #{expiry}") if (expiry <= 0)
|
659
|
+
@expiry = Time.at expiry
|
660
|
+
end
|
661
|
+
|
662
|
+
#####################################################################
|
663
|
+
# Sets the offers/actions for which the user granted consent.
|
664
|
+
#####################################################################
|
665
|
+
def offers=(offers)
|
666
|
+
if (offers.nil? or offers.empty?)
|
667
|
+
raise("Error: ConsentToken: Null offers.")
|
668
|
+
end
|
669
|
+
|
670
|
+
@offers_string = ""
|
671
|
+
@offers = []
|
672
|
+
|
673
|
+
offers = CGI.unescape(offers)
|
674
|
+
offers = offers.split(";")
|
675
|
+
offers.each{|offer|
|
676
|
+
offer = offer.split(":")[0]
|
677
|
+
@offers_string += "," unless @offers_string.empty?
|
678
|
+
@offers_string += offer
|
679
|
+
@offers.push(offer)
|
680
|
+
}
|
681
|
+
end
|
682
|
+
|
683
|
+
#####################################################################
|
684
|
+
# Sets the LocationID.
|
685
|
+
#####################################################################
|
686
|
+
def locationid=(locationid)
|
687
|
+
if (locationid.nil? or locationid.empty?)
|
688
|
+
raise("Error: ConsentToken: Null Location ID.")
|
689
|
+
end
|
690
|
+
@locationid = locationid
|
691
|
+
end
|
692
|
+
|
693
|
+
#####################################################################
|
694
|
+
# Makes a copy of the ConsentToken object.
|
695
|
+
#####################################################################
|
696
|
+
def copy(consenttoken)
|
697
|
+
@delegationtoken = consenttoken.delegationtoken
|
698
|
+
@refreshtoken = consenttoken.refreshtoken
|
699
|
+
@sessionkey = consenttoken.sessionkey
|
700
|
+
@expiry = consenttoken.expiry
|
701
|
+
@offers = consenttoken.offers
|
702
|
+
@locationid = consenttoken.locationid
|
703
|
+
@offers_string = consenttoken.offers_string
|
704
|
+
@decodedtoken = consenttoken.decodedtoken
|
705
|
+
@token = consenttoken.token
|
706
|
+
end
|
707
|
+
end
|
708
|
+
|
709
|
+
#####################################################################
|
710
|
+
# Processes the POST response from the Delegated Authentication
|
711
|
+
# service after a user has granted consent. The processConsent
|
712
|
+
# function extracts the consent token string and returns the result
|
713
|
+
# of invoking the processConsentToken method.
|
714
|
+
#####################################################################
|
715
|
+
def processConsent(query)
|
716
|
+
query = parse query
|
717
|
+
unless query
|
718
|
+
debug("Error: processConsent: Failed to parse query.")
|
719
|
+
return
|
720
|
+
end
|
721
|
+
action = query['action']
|
722
|
+
if action == 'cancel'
|
723
|
+
raise WindowsLiveLogin::PermissionDenied, 'Permission denied by end user'
|
724
|
+
elsif action != 'delauth'
|
725
|
+
debug("Warning: processConsent: query action ignored: #{action}.")
|
726
|
+
return
|
727
|
+
end
|
728
|
+
responsecode = query['ResponseCode']
|
729
|
+
unless responsecode == 'RequestApproved'
|
730
|
+
debug("Error: processConsent: Consent was not successfully granted: #{responsecode}")
|
731
|
+
return
|
732
|
+
end
|
733
|
+
token = query['ConsentToken']
|
734
|
+
context = CGI.unescape(query['appctx']) if query['appctx']
|
735
|
+
processConsentToken(token, context)
|
736
|
+
end
|
737
|
+
|
738
|
+
#####################################################################
|
739
|
+
# Processes the consent token string that is returned in the POST
|
740
|
+
# response by the Delegated Authentication service after a
|
741
|
+
# user has granted consent.
|
742
|
+
#####################################################################
|
743
|
+
def processConsentToken(token, context=nil)
|
744
|
+
if token.nil? or token.empty?
|
745
|
+
debug("Error: processConsentToken: Null token.")
|
746
|
+
return
|
747
|
+
end
|
748
|
+
decodedtoken = token
|
749
|
+
parsedtoken = parse(CGI.unescape(decodedtoken))
|
750
|
+
unless parsedtoken
|
751
|
+
debug("Error: processConsentToken: Failed to parse token: #{token}")
|
752
|
+
return
|
753
|
+
end
|
754
|
+
eact = parsedtoken['eact']
|
755
|
+
if eact
|
756
|
+
decodedtoken = decodeAndValidateToken eact
|
757
|
+
unless decodedtoken
|
758
|
+
debug("Error: processConsentToken: Failed to decode/validate token: #{token}")
|
759
|
+
return
|
760
|
+
end
|
761
|
+
parsedtoken = parse(decodedtoken)
|
762
|
+
decodedtoken = CGI.escape(decodedtoken)
|
763
|
+
end
|
764
|
+
begin
|
765
|
+
consenttoken = ConsentToken.new(self,
|
766
|
+
parsedtoken['delt'],
|
767
|
+
parsedtoken['reft'],
|
768
|
+
parsedtoken['skey'],
|
769
|
+
parsedtoken['exp'],
|
770
|
+
parsedtoken['offer'],
|
771
|
+
parsedtoken['lid'],
|
772
|
+
context, decodedtoken, token)
|
773
|
+
return consenttoken
|
774
|
+
rescue Exception => e
|
775
|
+
debug("Error: processConsentToken: Contents of token considered invalid: #{e}")
|
776
|
+
return
|
777
|
+
end
|
778
|
+
end
|
779
|
+
|
780
|
+
#####################################################################
|
781
|
+
# Attempts to obtain a new, refreshed token and return it. The
|
782
|
+
# original token is not modified.
|
783
|
+
#####################################################################
|
784
|
+
def refreshConsentToken(consenttoken, ru=nil)
|
785
|
+
if consenttoken.nil?
|
786
|
+
debug("Error: refreshConsentToken: Null consent token.")
|
787
|
+
return
|
788
|
+
end
|
789
|
+
refreshConsentToken2(consenttoken.offers_string, consenttoken.refreshtoken, ru)
|
790
|
+
end
|
791
|
+
|
792
|
+
#####################################################################
|
793
|
+
# Helper function to obtain a new, refreshed token and return it.
|
794
|
+
# The original token is not modified.
|
795
|
+
#####################################################################
|
796
|
+
def refreshConsentToken2(offers_string, refreshtoken, ru=nil)
|
797
|
+
url = nil
|
798
|
+
begin
|
799
|
+
url = getRefreshConsentTokenUrl(offers_string, refreshtoken, ru)
|
800
|
+
ret = fetch url
|
801
|
+
ret.value # raises exception if fetch failed
|
802
|
+
body = ret.body
|
803
|
+
body.scan(/\{"ConsentToken":"(.*)"\}/){|match|
|
804
|
+
return processConsentToken("#{match}")
|
805
|
+
}
|
806
|
+
debug("Error: refreshConsentToken2: Failed to extract token: #{body}")
|
807
|
+
rescue Exception => e
|
808
|
+
debug("Error: Failed to refresh consent token: #{e}")
|
809
|
+
end
|
810
|
+
return
|
811
|
+
end
|
812
|
+
end
|
813
|
+
|
814
|
+
#######################################################################
|
815
|
+
# Common methods.
|
816
|
+
#######################################################################
|
817
|
+
class WindowsLiveLogin
|
818
|
+
|
819
|
+
#####################################################################
|
820
|
+
# Decodes and validates the token.
|
821
|
+
#####################################################################
|
822
|
+
def decodeAndValidateToken(token, cryptkey=@cryptkey, signkey=@signkey,
|
823
|
+
internal_allow_recursion=true)
|
824
|
+
haveoldsecret = false
|
825
|
+
if (oldsecretexpiry and (Time.now.to_i < oldsecretexpiry.to_i))
|
826
|
+
haveoldsecret = true if (@oldcryptkey and @oldsignkey)
|
827
|
+
end
|
828
|
+
haveoldsecret = (haveoldsecret and internal_allow_recursion)
|
829
|
+
|
830
|
+
stoken = decodeToken(token, cryptkey)
|
831
|
+
stoken = validateToken(stoken, signkey) if stoken
|
832
|
+
if (stoken.nil? and haveoldsecret)
|
833
|
+
debug("Warning: Failed to validate token with current secret, attempting old secret.")
|
834
|
+
stoken = decodeAndValidateToken(token, @oldcryptkey, @oldsignkey, false)
|
835
|
+
end
|
836
|
+
stoken
|
837
|
+
end
|
838
|
+
|
839
|
+
#####################################################################
|
840
|
+
# Decodes the given token string; returns undef on failure.
|
841
|
+
#
|
842
|
+
# First, the string is URL-unescaped and base64 decoded.
|
843
|
+
# Second, the IV is extracted from the first 16 bytes of the string.
|
844
|
+
# Finally, the string is decrypted using the encryption key.
|
845
|
+
#####################################################################
|
846
|
+
def decodeToken(token, cryptkey=@cryptkey)
|
847
|
+
if (cryptkey.nil? or cryptkey.empty?)
|
848
|
+
fatal("Error: decodeToken: Secret key was not set. Aborting.")
|
849
|
+
end
|
850
|
+
token = u64(token)
|
851
|
+
if (token.nil? or (token.size <= 16) or !(token.size % 16).zero?)
|
852
|
+
debug("Error: decodeToken: Attempted to decode invalid token.")
|
853
|
+
return
|
854
|
+
end
|
855
|
+
iv = token[0..15]
|
856
|
+
crypted = token[16..-1]
|
857
|
+
begin
|
858
|
+
aes128cbc = OpenSSL::Cipher::AES128.new("CBC")
|
859
|
+
aes128cbc.decrypt
|
860
|
+
aes128cbc.iv = iv
|
861
|
+
aes128cbc.key = cryptkey
|
862
|
+
decrypted = aes128cbc.update(crypted) + aes128cbc.final
|
863
|
+
rescue Exception => e
|
864
|
+
debug("Error: decodeToken: Decryption failed: #{token}, #{e}")
|
865
|
+
return
|
866
|
+
end
|
867
|
+
decrypted
|
868
|
+
end
|
869
|
+
|
870
|
+
#####################################################################
|
871
|
+
# Creates a signature for the given string by using the signature
|
872
|
+
# key.
|
873
|
+
#####################################################################
|
874
|
+
def signToken(token, signkey=@signkey)
|
875
|
+
if (signkey.nil? or signkey.empty?)
|
876
|
+
fatal("Error: signToken: Secret key was not set. Aborting.")
|
877
|
+
end
|
878
|
+
begin
|
879
|
+
return HMAC::SHA256.digest(signkey, token)
|
880
|
+
rescue Exception => e
|
881
|
+
debug("Error: signToken: Signing failed: #{token}, #{e}")
|
882
|
+
return
|
883
|
+
end
|
884
|
+
end
|
885
|
+
|
886
|
+
#####################################################################
|
887
|
+
# Extracts the signature from the token and validates it.
|
888
|
+
#####################################################################
|
889
|
+
def validateToken(token, signkey=@signkey)
|
890
|
+
if (token.nil? or token.empty?)
|
891
|
+
debug("Error: validateToken: Null token.")
|
892
|
+
return
|
893
|
+
end
|
894
|
+
body, sig = token.split("&sig=")
|
895
|
+
if (body.nil? or sig.nil?)
|
896
|
+
debug("Error: validateToken: Invalid token: #{token}")
|
897
|
+
return
|
898
|
+
end
|
899
|
+
sig = u64(sig)
|
900
|
+
return token if (sig == signToken(body, signkey))
|
901
|
+
debug("Error: validateToken: Signature did not match.")
|
902
|
+
return
|
903
|
+
end
|
904
|
+
end
|
905
|
+
|
906
|
+
#######################################################################
|
907
|
+
# Implementation of the methods needed to perform Windows Live
|
908
|
+
# application verification as well as trusted sign-in.
|
909
|
+
#######################################################################
|
910
|
+
class WindowsLiveLogin
|
911
|
+
#####################################################################
|
912
|
+
# Generates an application verifier token. An IP address can
|
913
|
+
# optionally be included in the token.
|
914
|
+
#####################################################################
|
915
|
+
def getAppVerifier(ip=nil)
|
916
|
+
token = "appid=#{appid}&ts=#{timestamp}"
|
917
|
+
token += "&ip=#{ip}" if ip
|
918
|
+
token += "&sig=#{e64(signToken(token))}"
|
919
|
+
CGI.escape token
|
920
|
+
end
|
921
|
+
|
922
|
+
#####################################################################
|
923
|
+
# Returns the URL that is required to retrieve the application
|
924
|
+
# security token.
|
925
|
+
#
|
926
|
+
# By default, the application security token is generated for
|
927
|
+
# the Windows Live site; a specific Site ID can optionally be
|
928
|
+
# specified in 'siteid'. The IP address can also optionally be
|
929
|
+
# included in 'ip'.
|
930
|
+
#
|
931
|
+
# If 'js' is nil, a JavaScript Output Notation (JSON) response is
|
932
|
+
# returned in the following format:
|
933
|
+
#
|
934
|
+
# {"token":"<value>"}
|
935
|
+
#
|
936
|
+
# Otherwise, a JavaScript response is returned. It is assumed that
|
937
|
+
# WLIDResultCallback is a custom function implemented to handle the
|
938
|
+
# token value:
|
939
|
+
#
|
940
|
+
# WLIDResultCallback("<tokenvalue>");
|
941
|
+
#####################################################################
|
942
|
+
def getAppLoginUrl(siteid=nil, ip=nil, js=nil)
|
943
|
+
url = secureurl + "wapplogin.srf?app=#{getAppVerifier(ip)}"
|
944
|
+
url += "&alg=#{securityalgorithm}"
|
945
|
+
url += "&id=#{siteid}" if siteid
|
946
|
+
url += "&js=1" if js
|
947
|
+
url
|
948
|
+
end
|
949
|
+
|
950
|
+
#####################################################################
|
951
|
+
# Retrieves the application security token for application
|
952
|
+
# verification from the application sign-in URL.
|
953
|
+
#
|
954
|
+
# By default, the application security token will be generated for
|
955
|
+
# the Windows Live site; a specific Site ID can optionally be
|
956
|
+
# specified in 'siteid'. The IP address can also optionally be
|
957
|
+
# included in 'ip'.
|
958
|
+
#
|
959
|
+
# Implementation note: The application security token is downloaded
|
960
|
+
# from the application sign-in URL in JSON format:
|
961
|
+
#
|
962
|
+
# {"token":"<value>"}
|
963
|
+
#
|
964
|
+
# Therefore we must extract <value> from the string and return it as
|
965
|
+
# seen here.
|
966
|
+
#####################################################################
|
967
|
+
def getAppSecurityToken(siteid=nil, ip=nil)
|
968
|
+
url = getAppLoginUrl(siteid, ip)
|
969
|
+
begin
|
970
|
+
ret = fetch url
|
971
|
+
ret.value # raises exception if fetch failed
|
972
|
+
body = ret.body
|
973
|
+
body.scan(/\{"token":"(.*)"\}/){|match|
|
974
|
+
return match
|
975
|
+
}
|
976
|
+
debug("Error: getAppSecurityToken: Failed to extract token: #{body}")
|
977
|
+
rescue Exception => e
|
978
|
+
debug("Error: getAppSecurityToken: Failed to get token: #{e}")
|
979
|
+
end
|
980
|
+
return
|
981
|
+
end
|
982
|
+
|
983
|
+
#####################################################################
|
984
|
+
# Returns a string that can be passed to the getTrustedParams
|
985
|
+
# function as the 'retcode' parameter. If this is specified as the
|
986
|
+
# 'retcode', the application will be used as return URL after it
|
987
|
+
# finishes trusted sign-in.
|
988
|
+
#####################################################################
|
989
|
+
def getAppRetCode
|
990
|
+
"appid=#{appid}"
|
991
|
+
end
|
992
|
+
|
993
|
+
#####################################################################
|
994
|
+
# Returns a table of key-value pairs that must be posted to the
|
995
|
+
# sign-in URL for trusted sign-in. Use HTTP POST to do this. Be aware
|
996
|
+
# that the values in the table are neither URL nor HTML escaped and
|
997
|
+
# may have to be escaped if you are inserting them in code such as
|
998
|
+
# an HTML form.
|
999
|
+
#
|
1000
|
+
# The user to be trusted on the local site is passed in as string
|
1001
|
+
# 'user'.
|
1002
|
+
#
|
1003
|
+
# Optionally, 'retcode' specifies the resource to which successful
|
1004
|
+
# sign-in is redirected, such as Windows Live Mail, and is typically
|
1005
|
+
# a string in the format 'id=2000'. If you pass in the value from
|
1006
|
+
# getAppRetCode instead, sign-in will be redirected to the
|
1007
|
+
# application. Otherwise, an HTTP 200 response is returned.
|
1008
|
+
#####################################################################
|
1009
|
+
def getTrustedParams(user, retcode=nil)
|
1010
|
+
token = getTrustedToken(user)
|
1011
|
+
return unless token
|
1012
|
+
token = %{<wst:RequestSecurityTokenResponse xmlns:wst="http://schemas.xmlsoap.org/ws/2005/02/trust"><wst:RequestedSecurityToken><wsse:BinarySecurityToken xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">#{token}</wsse:BinarySecurityToken></wst:RequestedSecurityToken><wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"><wsa:EndpointReference xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"><wsa:Address>uri:WindowsLiveID</wsa:Address></wsa:EndpointReference></wsp:AppliesTo></wst:RequestSecurityTokenResponse>}
|
1013
|
+
params = {}
|
1014
|
+
params['wa'] = securityalgorithm
|
1015
|
+
params['wresult'] = token
|
1016
|
+
params['wctx'] = retcode if retcode
|
1017
|
+
params
|
1018
|
+
end
|
1019
|
+
|
1020
|
+
#####################################################################
|
1021
|
+
# Returns the trusted sign-in token in the format that is needed by a
|
1022
|
+
# control doing trusted sign-in.
|
1023
|
+
#
|
1024
|
+
# The user to be trusted on the local site is passed in as string
|
1025
|
+
# 'user'.
|
1026
|
+
#####################################################################
|
1027
|
+
def getTrustedToken(user)
|
1028
|
+
if user.nil? or user.empty?
|
1029
|
+
debug('Error: getTrustedToken: Null user specified.')
|
1030
|
+
return
|
1031
|
+
end
|
1032
|
+
token = "appid=#{appid}&uid=#{CGI.escape(user)}&ts=#{timestamp}"
|
1033
|
+
token += "&sig=#{e64(signToken(token))}"
|
1034
|
+
CGI.escape token
|
1035
|
+
end
|
1036
|
+
|
1037
|
+
#####################################################################
|
1038
|
+
# Returns the trusted sign-in URL to use for the Windows Live Login
|
1039
|
+
# server.
|
1040
|
+
#####################################################################
|
1041
|
+
def getTrustedLoginUrl
|
1042
|
+
secureurl + "wlogin.srf"
|
1043
|
+
end
|
1044
|
+
|
1045
|
+
#####################################################################
|
1046
|
+
# Returns the trusted sign-out URL to use for the Windows Live Login
|
1047
|
+
# server.
|
1048
|
+
#####################################################################
|
1049
|
+
def getTrustedLogoutUrl
|
1050
|
+
secureurl + "logout.srf?appid=#{appid}"
|
1051
|
+
end
|
1052
|
+
end
|
1053
|
+
|
1054
|
+
#######################################################################
|
1055
|
+
# Helper methods.
|
1056
|
+
#######################################################################
|
1057
|
+
class WindowsLiveLogin
|
1058
|
+
|
1059
|
+
#######################################################################
|
1060
|
+
# Function to parse the settings file.
|
1061
|
+
#######################################################################
|
1062
|
+
def parseSettings(settingsFile)
|
1063
|
+
settings = {}
|
1064
|
+
begin
|
1065
|
+
file = File.new(settingsFile)
|
1066
|
+
doc = REXML::Document.new file
|
1067
|
+
root = doc.root
|
1068
|
+
root.each_element{|e|
|
1069
|
+
settings[e.name] = e.text
|
1070
|
+
}
|
1071
|
+
rescue Exception => e
|
1072
|
+
fatal("Error: parseSettings: Error while reading #{settingsFile}: #{e}")
|
1073
|
+
end
|
1074
|
+
return settings
|
1075
|
+
end
|
1076
|
+
|
1077
|
+
#####################################################################
|
1078
|
+
# Derives the key, given the secret key and prefix as described in the
|
1079
|
+
# Web Authentication SDK documentation.
|
1080
|
+
#####################################################################
|
1081
|
+
def derive(secret, prefix)
|
1082
|
+
begin
|
1083
|
+
fatal("Nil/empty secret.") if (secret.nil? or secret.empty?)
|
1084
|
+
key = prefix + secret
|
1085
|
+
key = Digest::SHA256.digest(key)
|
1086
|
+
return key[0..15]
|
1087
|
+
rescue Exception => e
|
1088
|
+
debug("Error: derive: #{e}")
|
1089
|
+
return
|
1090
|
+
end
|
1091
|
+
end
|
1092
|
+
|
1093
|
+
#####################################################################
|
1094
|
+
# Parses query string and return a table
|
1095
|
+
# {String=>String}
|
1096
|
+
#
|
1097
|
+
# If a table is passed in from CGI.params, we convert it from
|
1098
|
+
# {String=>[]} to {String=>String}. I believe Rails uses symbols
|
1099
|
+
# instead of strings in general, so we convert from symbols to
|
1100
|
+
# strings here also.
|
1101
|
+
#####################################################################
|
1102
|
+
def parse(input)
|
1103
|
+
if (input.nil? or input.empty?)
|
1104
|
+
debug("Error: parse: Nil/empty input.")
|
1105
|
+
return
|
1106
|
+
end
|
1107
|
+
|
1108
|
+
pairs = {}
|
1109
|
+
if (input.class == String)
|
1110
|
+
input = input.split('&')
|
1111
|
+
input.each{|pair|
|
1112
|
+
k, v = pair.split('=')
|
1113
|
+
pairs[k] = v
|
1114
|
+
}
|
1115
|
+
else
|
1116
|
+
input.each{|k, v|
|
1117
|
+
v = v[0] if (v.class == Array)
|
1118
|
+
pairs[k.to_s] = v.to_s
|
1119
|
+
}
|
1120
|
+
end
|
1121
|
+
return pairs
|
1122
|
+
end
|
1123
|
+
|
1124
|
+
#####################################################################
|
1125
|
+
# Generates a time stamp suitable for the application verifier token.
|
1126
|
+
#####################################################################
|
1127
|
+
def timestamp
|
1128
|
+
Time.now.to_i.to_s
|
1129
|
+
end
|
1130
|
+
|
1131
|
+
#####################################################################
|
1132
|
+
# Base64-encodes and URL-escapes a string.
|
1133
|
+
#####################################################################
|
1134
|
+
def e64(s)
|
1135
|
+
return unless s
|
1136
|
+
CGI.escape Base64.encode64(s)
|
1137
|
+
end
|
1138
|
+
|
1139
|
+
#####################################################################
|
1140
|
+
# URL-unescapes and Base64-decodes a string.
|
1141
|
+
#####################################################################
|
1142
|
+
def u64(s)
|
1143
|
+
return unless s
|
1144
|
+
Base64.decode64 CGI.unescape(s)
|
1145
|
+
end
|
1146
|
+
|
1147
|
+
#####################################################################
|
1148
|
+
# Fetches the contents given a URL.
|
1149
|
+
#####################################################################
|
1150
|
+
def fetch(url)
|
1151
|
+
url = URI.parse url
|
1152
|
+
http = Net::HTTP.new(url.host, url.port)
|
1153
|
+
http.use_ssl = (url.scheme == "https")
|
1154
|
+
http.request_get url.request_uri
|
1155
|
+
end
|
1156
|
+
end
|