sso 0.1.0.alpha1 → 0.1.0.alpha2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/sso/benchmarking.rb +14 -0
- data/lib/sso/client.rb +7 -0
- data/lib/sso/client/README.md +92 -0
- data/lib/sso/client/omniauth/strategies/sso.rb +58 -0
- data/lib/sso/client/passport.rb +25 -0
- data/lib/sso/client/warden/hooks/after_fetch.rb +179 -0
- data/lib/sso/logging.rb +36 -0
- data/lib/sso/server.rb +26 -0
- data/lib/sso/server/README.md +13 -0
- data/lib/sso/server/authentications/passport.rb +170 -0
- data/lib/sso/server/configuration.rb +80 -0
- data/lib/sso/server/configure.rb +15 -0
- data/lib/sso/server/doorkeeper/access_token_marker.rb +111 -0
- data/lib/sso/server/doorkeeper/grant_marker.rb +85 -0
- data/lib/sso/server/doorkeeper/resource_owner_authenticator.rb +44 -0
- data/lib/sso/server/engine.rb +16 -0
- data/lib/sso/server/errors.rb +11 -0
- data/lib/sso/server/geolocations.rb +10 -0
- data/lib/sso/server/middleware/passport_verification.rb +30 -0
- data/lib/sso/server/passport.rb +92 -0
- data/lib/sso/server/passports.rb +148 -0
- data/lib/sso/server/warden/hooks/after_authentication.rb +47 -0
- data/lib/sso/server/warden/hooks/before_logout.rb +38 -0
- data/lib/sso/server/warden/strategies/passport.rb +39 -0
- metadata +25 -2
- data/lib/sso.rb +0 -6
@@ -0,0 +1,13 @@
|
|
1
|
+
# Setting up an SSO server
|
2
|
+
|
3
|
+
### Assumptions
|
4
|
+
|
5
|
+
* You use doorkeeper as a Rails OAuth server.
|
6
|
+
* You want to provide single-sign-on for the end-users.
|
7
|
+
* All OAuth clients ("consumers") are developed by you. You have full control over them and can automatically trust them (i.e. you can set `skip_authorization { true }` in your doorkeeper.rb initializer). This makes sense, because why would you ask the end-user for permission to login to another subsystem of your SSO world? The whole idea with SSO is that your users don't need to notice switching between the OAuth clients.
|
8
|
+
* The SSO session is to be browser-wide and app-wide. If you click on "login" you will be logged in on every client web app in that browser. If you click on "logout" you will be logged out of every client web app in that browser.
|
9
|
+
* You use warden to login at the SSO server, it is, however, **not** okay to use scopes here. That's an assumption which makes this gem dramatically more simple and I didn't find a downside yet (Warden scopes are not really an ideal authorization solution anyway).
|
10
|
+
|
11
|
+
### Setup
|
12
|
+
|
13
|
+
For now, see [these point of interests](https://github.com/halo/sso/search?q=POI) to see how exactly a rails app can be setup.
|
@@ -0,0 +1,170 @@
|
|
1
|
+
module SSO
|
2
|
+
module Server
|
3
|
+
module Authentications
|
4
|
+
class Passport
|
5
|
+
include ::SSO::Logging
|
6
|
+
|
7
|
+
def initialize(request)
|
8
|
+
@request = request
|
9
|
+
end
|
10
|
+
|
11
|
+
def authenticate
|
12
|
+
result = authenticate!
|
13
|
+
|
14
|
+
if result.success?
|
15
|
+
result
|
16
|
+
else
|
17
|
+
# TODO: Prevent Flooding here.
|
18
|
+
debug { "The Passport authentication failed: #{result.code}" }
|
19
|
+
Operations.failure :passport_authentication_failed, object: failure_rack_array
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :request
|
26
|
+
|
27
|
+
def authenticate!
|
28
|
+
check_arguments { |failure| return failure }
|
29
|
+
|
30
|
+
unless valid_signature?
|
31
|
+
warn { 'I found the corresponding passport, but the request was not properly signed with it.' }
|
32
|
+
return Operations.failure :invalid_signature, object: failure_rack_array
|
33
|
+
end
|
34
|
+
|
35
|
+
debug { 'The request was properly signed, I found the corresponding passport.' }
|
36
|
+
update_passport
|
37
|
+
|
38
|
+
if passport.state == state
|
39
|
+
Operations.success :signature_approved_no_changes, object: success_same_state_rack_array
|
40
|
+
else
|
41
|
+
debug { "The current user state #{passport.state.inspect} does not match the provided state #{state.inspect}" }
|
42
|
+
Operations.success :signature_approved_state_changed, object: success_new_state_rack_array
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def check_arguments
|
47
|
+
yield Operations.failure :missing_verb if verb.blank?
|
48
|
+
yield Operations.failure :missing_passport_id if passport_id.blank?
|
49
|
+
yield Operations.failure :missing_state if state.blank?
|
50
|
+
yield Operations.failure :passport_not_found if passport.blank?
|
51
|
+
yield Operations.failure :passport_revoked if passport.invalid?
|
52
|
+
# yield Operations.failure :user_not_encapsulated if passport.user.blank?
|
53
|
+
Operations.success :arguments_are_valid
|
54
|
+
end
|
55
|
+
|
56
|
+
def success_new_state_rack_array
|
57
|
+
payload = { success: true, code: :passport_changed, passport: passport.export }
|
58
|
+
[200, { 'Content-Type' => 'application/json' }, [payload.to_json]]
|
59
|
+
end
|
60
|
+
|
61
|
+
def success_same_state_rack_array
|
62
|
+
payload = { success: true, code: :passpord_unmodified }
|
63
|
+
[200, { 'Content-Type' => 'application/json' }, [payload.to_json]]
|
64
|
+
end
|
65
|
+
|
66
|
+
# You might be wondering why we don't simply return a 401 or 404 status code.
|
67
|
+
# The reason is that the receiving end would have no way to determine whether that reply is due to a
|
68
|
+
# nginx configuration error or because the passport is actually invalid. We don't want to revoke
|
69
|
+
# all passports simply because a load balancer is pointing to the wrong Rails application or something.
|
70
|
+
#
|
71
|
+
def failure_rack_array
|
72
|
+
payload = { success: true, code: :passport_invalid }
|
73
|
+
[200, { 'Content-Type' => 'application/json' }, [payload.to_json]]
|
74
|
+
end
|
75
|
+
|
76
|
+
def passport
|
77
|
+
@passport ||= backend.find_by_id passport_id
|
78
|
+
end
|
79
|
+
|
80
|
+
def passport_id
|
81
|
+
signature_request.authenticate { |passport_id| return passport_id }
|
82
|
+
rescue Signature::AuthenticationError
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def valid_signature?
|
87
|
+
signature_request.authenticate { Signature::Token.new passport_id, passport.secret }
|
88
|
+
true
|
89
|
+
rescue Signature::AuthenticationError
|
90
|
+
debug { 'It looks like the API signature for the passport verification was incorrect.' }
|
91
|
+
false
|
92
|
+
end
|
93
|
+
|
94
|
+
def signature_request
|
95
|
+
@signature_request ||= Signature::Request.new verb, path, params
|
96
|
+
end
|
97
|
+
|
98
|
+
def update_passport
|
99
|
+
debug { "Will update activity of Passport #{passport.id} if neccesary..." }
|
100
|
+
if passport.ip.to_s == ip.to_s && passport.agent.to_s == user_agent.to_s
|
101
|
+
debug { "No changes in IP or User Agent so I won't perform an update now..." }
|
102
|
+
Operations.success :already_up_to_date
|
103
|
+
else
|
104
|
+
debug { "Yes, it is necessary, updating activity of Passport #{passport.id}" }
|
105
|
+
passport.update_attributes ip: ip.to_s, agent: user_agent, activity_at: Time.now
|
106
|
+
Operations.success :passport_metadata_updated
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def application
|
111
|
+
passport.application
|
112
|
+
end
|
113
|
+
|
114
|
+
def app_scopes
|
115
|
+
application.scopes
|
116
|
+
end
|
117
|
+
|
118
|
+
def insider?
|
119
|
+
if app_scopes.empty?
|
120
|
+
warn { "Doorkeeper::Application #{application.name} with ID #{application.id} has no scope restrictions. Assuming 'outsider' for now." }
|
121
|
+
return false
|
122
|
+
end
|
123
|
+
|
124
|
+
app_scopes.has_scopes? [:insider]
|
125
|
+
end
|
126
|
+
|
127
|
+
def ip
|
128
|
+
if insider?
|
129
|
+
params['ip']
|
130
|
+
else
|
131
|
+
request_ip
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def user_agent
|
136
|
+
if insider?
|
137
|
+
params['user_agent']
|
138
|
+
else
|
139
|
+
request.user_agent
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def request_ip
|
144
|
+
request.env['action_dispatch.remote_ip'] || fail('Whoops, I thought you were using Rails, but action_dispatch.remote_ip is empty!')
|
145
|
+
end
|
146
|
+
|
147
|
+
def verb
|
148
|
+
request.request_method
|
149
|
+
end
|
150
|
+
|
151
|
+
def path
|
152
|
+
request.path
|
153
|
+
end
|
154
|
+
|
155
|
+
def params
|
156
|
+
request.params
|
157
|
+
end
|
158
|
+
|
159
|
+
def state
|
160
|
+
params['state']
|
161
|
+
end
|
162
|
+
|
163
|
+
def backend
|
164
|
+
::SSO::Server::Passport
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module SSO
|
4
|
+
class Configuration
|
5
|
+
|
6
|
+
def human_readable_location_for_ip
|
7
|
+
@human_readable_location_for_ip || default_human_readable_location_for_ip
|
8
|
+
end
|
9
|
+
attr_writer :human_readable_location_for_ip
|
10
|
+
|
11
|
+
def exception_handler
|
12
|
+
@exception_handler || default_exception_handler
|
13
|
+
end
|
14
|
+
attr_writer :exception_handler
|
15
|
+
|
16
|
+
def user_state_base
|
17
|
+
@user_state_base || fail('You need to configure user_state_base, see SSO::Configuration for more info.')
|
18
|
+
end
|
19
|
+
attr_writer :user_state_base
|
20
|
+
|
21
|
+
def find_user_for_passport
|
22
|
+
@find_user_for_passport || fail('You need to configure find_user_for_passport, see SSO::Configuration for more info.')
|
23
|
+
end
|
24
|
+
attr_writer :find_user_for_passport
|
25
|
+
|
26
|
+
def user_state_key
|
27
|
+
@user_state_key || fail('You need to configure a secret user_state_key, see SSO::Configuration for more info.')
|
28
|
+
end
|
29
|
+
attr_writer :user_state_key
|
30
|
+
|
31
|
+
def logger
|
32
|
+
@logger ||= default_logger
|
33
|
+
end
|
34
|
+
attr_writer :logger
|
35
|
+
|
36
|
+
def environment
|
37
|
+
@environment ||= default_environment
|
38
|
+
end
|
39
|
+
attr_writer :environment
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def default_logger
|
44
|
+
return ::Rails.logger if defined?(::Rails)
|
45
|
+
instance = ::Logger.new STDOUT
|
46
|
+
instance.level = default_log_level
|
47
|
+
instance
|
48
|
+
end
|
49
|
+
|
50
|
+
def default_log_level
|
51
|
+
case environment
|
52
|
+
when 'production' then ::Logger::WARN
|
53
|
+
when 'test' then ::Logger::UNKNOWN
|
54
|
+
else ::Logger::DEBUG
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def default_environment
|
59
|
+
return ::Rails.env if defined?(::Rails)
|
60
|
+
return ENV['RACK_ENV'].to_s if ENV['RACK_ENV'].to_s != ''
|
61
|
+
'unknown'
|
62
|
+
end
|
63
|
+
|
64
|
+
def default_exception_handler
|
65
|
+
proc do
|
66
|
+
return unless ::SSO.config.logger
|
67
|
+
::SSO.config.logger.error(self.class) do
|
68
|
+
"An internal error occured #{exception.class.name} #{exception.message} #{exception.backtrace[0..5].join(' ') if exception.backtrace}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def default_human_readable_location_for_ip
|
74
|
+
proc do
|
75
|
+
'Unknown'
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module SSO
|
2
|
+
|
3
|
+
# Public: Lazy-loads and returns the the configuration instance.
|
4
|
+
#
|
5
|
+
def self.config
|
6
|
+
@config ||= ::SSO::Configuration.new
|
7
|
+
end
|
8
|
+
|
9
|
+
# Public: Yields the configuration instance.
|
10
|
+
#
|
11
|
+
def self.configure
|
12
|
+
yield config
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module SSO
|
2
|
+
module Server
|
3
|
+
module Doorkeeper
|
4
|
+
class AccessTokenMarker
|
5
|
+
include ::SSO::Logging
|
6
|
+
|
7
|
+
attr_reader :request, :response
|
8
|
+
delegate :params, to: :request
|
9
|
+
|
10
|
+
def initialize(app)
|
11
|
+
@app = app
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
@env = env
|
16
|
+
@request = ::ActionDispatch::Request.new @env
|
17
|
+
@response = @app.call @env
|
18
|
+
|
19
|
+
return response unless applicable?
|
20
|
+
|
21
|
+
if authorization_grant_flow?
|
22
|
+
handle_authorization_grant_flow
|
23
|
+
elsif password_flow?
|
24
|
+
handle_password_flow
|
25
|
+
else
|
26
|
+
fail NotImplementedError
|
27
|
+
end
|
28
|
+
|
29
|
+
response
|
30
|
+
end
|
31
|
+
|
32
|
+
def applicable?
|
33
|
+
request.method == 'POST' &&
|
34
|
+
(authorization_grant_flow? || password_flow?) &&
|
35
|
+
response_code == 200 &&
|
36
|
+
response_body &&
|
37
|
+
outgoing_access_token
|
38
|
+
end
|
39
|
+
|
40
|
+
def handle_authorization_grant_flow
|
41
|
+
# We cannot rely on session[:passport_id] here because the end-user might have cookies disabled.
|
42
|
+
# The only thing we can rely on to identify the user/Passport is the incoming grant token.
|
43
|
+
debug { %(Detected outgoing "Access Token" #{outgoing_access_token.inspect} of the "Authorization Code Grant" flow) }
|
44
|
+
debug { %(This Access Token belongs to "Authorization Grant Token" #{grant_token.inspect}. Augmenting related Passport with it...) }
|
45
|
+
registration = ::SSO::Server::Passports.register_access_token_from_grant grant_token: grant_token, access_token: outgoing_access_token
|
46
|
+
|
47
|
+
return if registration.success?
|
48
|
+
warn { 'The passport could not be augmented. Destroying warden session.' }
|
49
|
+
warden.logout
|
50
|
+
end
|
51
|
+
|
52
|
+
def handle_password_flow
|
53
|
+
local_passport_id = session[:passport_id] # <- We know this is always set because it was set in this very response
|
54
|
+
debug { %(Detected outgoing "Access Token" #{outgoing_access_token.inspect} of the "Resource Owner Password Credentials Grant" flow.) }
|
55
|
+
debug { %(Augmenting local Passport #{local_passport_id.inspect} with this outgoing Access Token...) }
|
56
|
+
generation = ::SSO::Server::Passports.register_access_token passport_id: local_passport_id, access_token: outgoing_access_token
|
57
|
+
|
58
|
+
return if generation.success?
|
59
|
+
warn { 'The passport could not be generated. Destroying warden session.' }
|
60
|
+
warden.logout
|
61
|
+
end
|
62
|
+
|
63
|
+
def response_body
|
64
|
+
response.last.first.presence
|
65
|
+
end
|
66
|
+
|
67
|
+
def response_code
|
68
|
+
response.first
|
69
|
+
end
|
70
|
+
|
71
|
+
def parsed_response_body
|
72
|
+
return unless response_body
|
73
|
+
::JSON.parse response_body
|
74
|
+
rescue JSON::ParserError => exception
|
75
|
+
Trouble.notify exception
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
def outgoing_access_token
|
80
|
+
return unless parsed_response_body
|
81
|
+
parsed_response_body['access_token']
|
82
|
+
end
|
83
|
+
|
84
|
+
def warden
|
85
|
+
request.env['warden']
|
86
|
+
end
|
87
|
+
|
88
|
+
def authorization_grant_flow?
|
89
|
+
grant_token.present?
|
90
|
+
end
|
91
|
+
|
92
|
+
def password_flow?
|
93
|
+
grant_type == 'password'
|
94
|
+
end
|
95
|
+
|
96
|
+
def grant_token
|
97
|
+
params['code']
|
98
|
+
end
|
99
|
+
|
100
|
+
def grant_type
|
101
|
+
params['grant_type']
|
102
|
+
end
|
103
|
+
|
104
|
+
def session
|
105
|
+
@env['rack.session']
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module SSO
|
2
|
+
module Server
|
3
|
+
module Doorkeeper
|
4
|
+
class GrantMarker
|
5
|
+
include ::SSO::Logging
|
6
|
+
|
7
|
+
attr_reader :response
|
8
|
+
delegate :session, to: :request
|
9
|
+
|
10
|
+
def initialize(app)
|
11
|
+
@app = app
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
@env = env
|
16
|
+
@response = @app.call @env
|
17
|
+
|
18
|
+
return response unless outgoing_grant_token
|
19
|
+
|
20
|
+
if passport_id
|
21
|
+
debug { %(Detected outgoing "Authorization Grant Token" #{outgoing_grant_token.inspect} of the "Authorization Code Grant" flow.) }
|
22
|
+
debug { %(Augmenting Passport #{passport_id.inspect} with this outgoing Grant Token...) }
|
23
|
+
registration = ::SSO::Server::Passports.register_authorization_grant passport_id: passport_id, token: outgoing_grant_token
|
24
|
+
|
25
|
+
if registration.failure?
|
26
|
+
warn { 'The passport could not be augmented. Destroying warden session.' }
|
27
|
+
warden.logout
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
response
|
32
|
+
end
|
33
|
+
|
34
|
+
def request
|
35
|
+
::ActionDispatch::Request.new @env
|
36
|
+
end
|
37
|
+
|
38
|
+
def code
|
39
|
+
response.first
|
40
|
+
end
|
41
|
+
|
42
|
+
def warden
|
43
|
+
request.env['warden']
|
44
|
+
end
|
45
|
+
|
46
|
+
def passport_id
|
47
|
+
session['passport_id']
|
48
|
+
end
|
49
|
+
|
50
|
+
def location_header
|
51
|
+
unless code == 302
|
52
|
+
# debug { "Uninteresting response, because it is not a redirect" }
|
53
|
+
return
|
54
|
+
end
|
55
|
+
|
56
|
+
response.second['Location']
|
57
|
+
end
|
58
|
+
|
59
|
+
def redirect_uri
|
60
|
+
unless location_header
|
61
|
+
# debug { "Uninteresting response, because there is no Location header" }
|
62
|
+
return
|
63
|
+
end
|
64
|
+
|
65
|
+
::URI.parse location_header
|
66
|
+
end
|
67
|
+
|
68
|
+
def redirect_uri_params
|
69
|
+
return unless redirect_uri
|
70
|
+
::Rack::Utils.parse_query redirect_uri.query
|
71
|
+
end
|
72
|
+
|
73
|
+
def outgoing_grant_token
|
74
|
+
unless redirect_uri_params && redirect_uri_params['code']
|
75
|
+
# debug { "Uninteresting response, because there is no code parameter sent" }
|
76
|
+
return
|
77
|
+
end
|
78
|
+
|
79
|
+
redirect_uri_params['code']
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|