-
Notifications
You must be signed in to change notification settings - Fork 482
/
omniauth_callbacks_controller.rb
330 lines (285 loc) · 11.8 KB
/
omniauth_callbacks_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
require 'cdo/shared_cache'
require 'honeybadger'
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include UsersHelper
skip_before_action :clear_sign_up_session_vars
# GET /users/auth/:provider/callback
def all
if should_connect_provider?
connect_provider
else
login
end
end
AuthenticationOption::OAUTH_CREDENTIAL_TYPES.each do |provider|
alias_method provider.to_sym, :all
end
# Call GET /users/auth/:provider/connect and the callback will trigger this code path
def connect_provider
return head(:bad_request) unless can_connect_provider?
auth_hash = request.env['omniauth.auth']
provider = auth_hash.provider.to_s
return head(:bad_request) unless AuthenticationOption::OAUTH_CREDENTIAL_TYPES.include? provider
# TODO: some of this won't work right for non-Google providers, because info comes in differently
new_data = nil
if auth_hash.credentials && (auth_hash.credentials.token || auth_hash.credentials.expires_at || auth_hash.credentials.refresh_token)
new_data = {
oauth_token: auth_hash.credentials.token,
oauth_token_expiration: auth_hash.credentials.expires_at,
oauth_refresh_token: auth_hash.credentials.refresh_token
}.to_json
end
email = auth_hash.info.email
hashed_email = nil
hashed_email = User.hash_email(email) unless email.blank?
auth_option = AuthenticationOption.new(
user: current_user,
email: email,
hashed_email: hashed_email || '',
credential_type: provider,
authentication_id: auth_hash.uid,
data: new_data
)
if auth_option.save
flash.notice = I18n.t('user.account_successfully_updated')
else
flash.alert = get_connect_provider_errors(auth_option)
end
redirect_to edit_user_registration_path
end
def login
auth_hash = request.env['omniauth.auth']
auth_params = request.env['omniauth.params']
provider = auth_hash.provider.to_s
session[:sign_up_type] = provider
# Fiddle with data if it's a Powerschool request (other OpenID 2.0 providers might need similar treatment if we add any)
if provider == 'powerschool'
auth_hash = extract_powerschool_data(request.env["omniauth.auth"])
end
# Microsoft formats email and name differently, so update it to match expected structure
if provider == AuthenticationOption::MICROSOFT
auth_hash = extract_microsoft_data(request.env["omniauth.auth"])
end
@user = User.from_omniauth(auth_hash, auth_params, session)
# Set user-account locale only if no cookie is already set.
if @user.locale &&
@user.locale != request.env['cdo.locale'] &&
cookies[:language_].nil?
set_locale_cookie(@user.locale)
end
if just_authorized_google_classroom(@user, request.env['omniauth.params'])
# Redirect to open roster dialog on home page if user just authorized access
# to Google Classroom courses and rosters
redirect_to '/home?open=rosterDialog'
elsif User::OAUTH_PROVIDERS_UNTRUSTED_EMAIL.include?(provider) && @user.persisted?
handle_untrusted_email_signin(@user, provider)
elsif allows_silent_takeover(@user, auth_hash) || allows_google_classroom_takeover(@user)
silent_takeover(@user, auth_hash)
sign_in_user
elsif @user.persisted?
# If email is already taken, persisted? will be false because of a validation failure
check_and_apply_oauth_takeover(@user)
sign_in_user
elsif (looked_up_user = User.find_by_email_or_hashed_email(@user.email))
# Note that @user.email is populated by User.from_omniauth even for students
if looked_up_user.provider == 'clever'
redirect_to "/users/sign_in?providerNotLinked=#{provider}&useClever=true"
else
redirect_to "/users/sign_in?providerNotLinked=#{provider}&email=#{@user.email}"
end
else
# This is a new registration
register_new_user(@user)
end
end
OAUTH_PARAMS_TO_STRIP = %w{oauth_token oauth_refresh_token}.freeze
def self.get_cache_key(oauth_param, user)
"#{oauth_param}-#{user.email}"
end
private
def register_new_user(user)
move_oauth_params_to_cache(user)
session["devise.user_attributes"] = user.attributes
# For some providers, signups can happen without ever having hit the sign_up page, where
# our tracking data is usually populated, so do it here
SignUpTracking.begin_sign_up_tracking(session)
redirect_to new_user_registration_url
end
# TODO: figure out how to avoid skipping CSRF verification for Powerschool
skip_before_action :verify_authenticity_token, only: :powerschool
def extract_powerschool_data(auth)
# OpenID 2.0 data comes back in a different format compared to most of our other oauth data.
args = JSON.parse(auth.extra.response.message.to_json)['args']
auth_info = auth.info.merge(OmniAuth::AuthHash.new(
user_type: args["[\"http://openid.net/srv/ax/1.0\", \"value.ext0\"]"],
email: args["[\"http://openid.net/srv/ax/1.0\", \"value.ext1\"]"],
name: {
first: args["[\"http://openid.net/srv/ax/1.0\", \"value.ext2\"]"],
last: args["[\"http://openid.net/srv/ax/1.0\", \"value.ext3\"]"],
},
)
)
auth.info = auth_info
auth
end
def extract_microsoft_data(auth)
auth_info = auth.info.merge(OmniAuth::AuthHash.new(
email: auth[:extra][:raw_info][:userPrincipalName],
name: auth[:extra][:raw_info][:displayName]
)
)
auth.info = auth_info
auth
end
# Clever/Powerschool signins have unique requirements, and must be handled a bit outside the normal flow
def handle_untrusted_email_signin(user, provider)
force_takeover = user.teacher? && user.email.present? && user.email.end_with?('.oauthemailalreadytaken')
# We used to check this based on sign_in_count, but we're explicitly logging it now
seen_oauth_takeover_dialog = (!!user.seen_oauth_connect_dialog) || user.sign_in_count > 1
# If account exists (as looked up by Clever ID) and it's not the first login, just sign in
if user.persisted? && seen_oauth_takeover_dialog && !force_takeover
sign_in_user
else
# Otherwise, it's either the first login, or a user who must connect -
# offer to connect the Clever account to an existing one, or insist if needed
if user.migrated?
auth_option = user.authentication_options.find_by credential_type: provider
begin_account_takeover \
provider: provider,
uid: auth_option.authentication_id,
oauth_token: auth_option.data_hash[:oauth_token],
force_takeover: force_takeover
else
begin_account_takeover \
provider: user.provider,
uid: user.uid,
oauth_token: user.oauth_token,
force_takeover: force_takeover
end
user.seen_oauth_connect_dialog = true
user.save!
sign_in_user
end
end
def move_oauth_params_to_cache(user)
# Because some oauth tokens are quite large, we strip them from the session
# variables and pass them through via the cache instead - they are pulled out again
# from User::new_with_session
cache = CDO.shared_cache
return unless cache
OAUTH_PARAMS_TO_STRIP.each do |param|
param_value = user.attributes['properties'].delete(param)
cache_key = OmniauthCallbacksController.get_cache_key(param, user)
cache.write(cache_key, param_value)
end
end
def just_authorized_google_classroom(user, params)
scopes = (params['scope'] || '').split(',')
user.persisted? &&
user.provider == 'google_oauth2' &&
scopes.include?('classroom.rosters.readonly')
end
def allows_google_classroom_takeover(user)
# Google Classroom does not provide student email addresses, so we want to perform
# silent takeover on these accounts, but *only if* the student hasn't made progress
# with the account created during the Google Classroom import.
user.persisted? && user.google_classroom_student? &&
user.email.blank? && user.hashed_email.blank? &&
!user.has_activity?
end
def silent_takeover(oauth_user, auth_hash)
lookup_email = oauth_user.email.presence || auth_hash.info.email
lookup_user = User.find_by_email_or_hashed_email(lookup_email)
unless lookup_user.present?
# Even if silent takeover is not available for student imported from Google Classroom, we still want
# to attach the email received from Google login to the student's account since GC imports do not provide emails.
if allows_google_classroom_takeover(oauth_user)
oauth_user.update_email_for(
provider: auth_hash.provider.to_s,
uid: auth_hash.uid,
email: lookup_email
)
end
return
end
# Continue with silent takeover
@user = lookup_user
# Transfer sections and destroy Google Classroom user if takeover is possible
if allows_google_classroom_takeover(oauth_user)
return unless move_sections_and_destroy_source_user(
source_user: oauth_user,
destination_user: @user,
takeover_type: 'silent'
)
end
if @user.migrated?
success = AuthenticationOption.create(
user: @user,
email: lookup_email,
credential_type: auth_hash.provider.to_s,
authentication_id: auth_hash.uid,
data: {
oauth_token: auth_hash.credentials&.token,
oauth_token_expiration: auth_hash.credentials&.expires_at,
oauth_refresh_token: auth_hash.credentials&.refresh_token
}.to_json
)
unless success
# This should never happen if other logic is working correctly, so notify
Honeybadger.notify(
error_class: 'Failed to create AuthenticationOption during silent takeover',
error_message: "Could not create AuthenticationOption during silent takeover for user with email #{lookup_email}"
)
return
end
else
success = @user.update(
provider: auth_hash.provider.to_s,
uid: auth_hash.uid,
oauth_token: auth_hash.credentials&.token,
oauth_token_expiration: auth_hash.credentials&.expires_at,
oauth_refresh_token: auth_hash.credentials&.refresh_token
)
unless success
# This should never happen if other logic is working correctly, so notify
Honeybadger.notify(
error_class: 'Failed to update User during silent takeover',
error_message: "Could not update user during silent takeover for user with email #{lookup_email}"
)
return
end
end
end
def sign_in_user
flash.notice = I18n.t('auth.signed_in')
# Will only log if the sign_up page session cookie is set, so this is safe to call in all cases
SignUpTracking.log_sign_in(resource, session, request)
sign_in_and_redirect @user
end
def allows_silent_takeover(oauth_user, auth_hash)
allow_takeover = auth_hash.provider.present?
allow_takeover &= AuthenticationOption::SILENT_TAKEOVER_CREDENTIAL_TYPES.include?(auth_hash.provider.to_s)
lookup_user = User.find_by_email_or_hashed_email(oauth_user.email)
allow_takeover && lookup_user && !oauth_user.persisted?
end
def should_connect_provider?
return current_user && session[:connect_provider].present?
end
def can_connect_provider?
return false unless current_user&.migrated?
connect_flag_expiration = session.delete :connect_provider
connect_flag_expiration&.future?
end
def get_connect_provider_errors(auth_option)
errors = auth_option.errors.full_messages
Honeybadger.notify(
error_message: "Error connecting to provider",
context: {
authentication_option: auth_option,
errors: errors
}
)
return errors.first unless errors.empty?
I18n.t('auth.unable_to_connect_provider', provider: I18n.t("auth.#{auth_option.credential_type}"))
end
end