oauth2 confirmation screen, closes #7762

This explicit confirmation step is an improvement on our
login-and-implicitly-accept workflow from before. And it allows us to do
the oauth workflow without forcing a logout, which is much more ideal
especially for embedded LTI tools that want to use oauth.

Eventually this dialog will contain more information on the app and the
permissions requested.

test plan:
As a client application, kick off the oauth workflow for a logged-in
user, verify the user goes straight to the confirmation screen. Verify
you only get a code back if they accept, and an error if they deny. Do
the same without a web session, verify you go to the confirmation screen
straight after logging in.

Change-Id: Idf9905b795979339aec0cb5e4e058f4507a81bac
Reviewed-on: https://gerrit.instructure.com/9804
Tested-by: Hudson <hudson@instructure.com>
Reviewed-by: Brian Whitmer <brian@instructure.com>
This commit is contained in:
Brian Palmer 2012-03-27 14:16:35 -06:00
parent 73666d9178
commit ad8f17d07e
7 changed files with 195 additions and 37 deletions

View File

@ -362,21 +362,7 @@ class PseudonymSessionsController < ApplicationController
respond_to do |format|
flash[:notice] = t 'notices.login_success', "Login successful." unless flash[:error]
if session[:oauth2]
# this is where we will verify client authorization and scopes, once implemented
# .....
# now generate the temporary code, and respond/redirect
code = ActiveSupport::SecureRandom.hex(64)
code_data = { 'user' => user.id, 'client_id' => session[:oauth2][:client_id] }
Canvas.redis.setex("oauth2:#{code}", 1.day, code_data.to_json)
redirect_uri = session[:oauth2][:redirect_uri]
if redirect_uri == OAUTH2_OOB_URI
# destroy this user session, it's only for generating the token
@pseudonym_session.try(:destroy)
reset_session
format.html { redirect_to oauth2_auth_url(:code => code) }
else
format.html { redirect_to "#{redirect_uri}?code=#{code}" }
end
return redirect_to(oauth2_auth_confirm_url)
elsif session[:course_uuid] && user && (course = Course.find_by_uuid_and_workflow_state(session[:course_uuid], "created"))
claim_session_course(course, user)
format.html { redirect_to(course_url(course, :login_success => '1')) }
@ -396,26 +382,48 @@ class PseudonymSessionsController < ApplicationController
OAUTH2_OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'
def oauth2_auth
if params[:code]
if params[:code] || params[:error]
# hopefully the user never sees this, since it's an oob response and the
# browser should be closed automatically. but we'll at least display
# something basic.
return render()
end
key = DeveloperKey.find_by_id(params[:client_id]) if params[:client_id].present?
unless key
@key = DeveloperKey.find_by_id(params[:client_id]) if params[:client_id].present?
unless @key
return render(:status => 400, :json => { :message => "invalid client_id" })
end
redirect_uri = params[:redirect_uri].presence || ""
unless redirect_uri == OAUTH2_OOB_URI || key.redirect_domain_matches?(redirect_uri)
unless redirect_uri == OAUTH2_OOB_URI || @key.redirect_domain_matches?(redirect_uri)
return render(:status => 400, :json => { :message => "invalid redirect_uri" })
end
session[:oauth2] = { :client_id => key.id, :redirect_uri => redirect_uri }
# force the user to re-authenticate
redirect_to login_url(:re_login => true)
session[:oauth2] = { :client_id => @key.id, :redirect_uri => redirect_uri }
if @current_pseudonym
redirect_to oauth2_auth_confirm_url
else
redirect_to login_url
end
end
def oauth2_confirm
@key = DeveloperKey.find(session[:oauth2][:client_id])
@app_name = @key.name.presence || @key.user_name.presence || @key.email.presence || t(:default_app_name, "Third-Party Application")
end
def oauth2_accept
# now generate the temporary code, and respond/redirect
code = ActiveSupport::SecureRandom.hex(64)
code_data = { 'user' => @current_user.id, 'client_id' => session[:oauth2][:client_id] }
Canvas.redis.setex("oauth2:#{code}", 1.day, code_data.to_json)
final_oauth2_redirect(session[:oauth2][:redirect_uri], :code => code)
session.delete(:oauth2)
end
def oauth2_deny
final_oauth2_redirect(session[:oauth2][:redirect_uri], :error => "access_denied")
session.delete(:oauth2)
end
def oauth2_token
@ -445,4 +453,13 @@ class PseudonymSessionsController < ApplicationController
'user' => user.as_json(:only => [:id, :name], :include_root => false),
}
end
def final_oauth2_redirect(redirect_uri, opts = {})
if redirect_uri == OAUTH2_OOB_URI
redirect_to oauth2_auth_url(opts)
else
has_params = redirect_uri =~ %r{\?}
redirect_to(redirect_uri + (has_params ? "&" : "?") + opts.to_query)
end
end
end

View File

@ -1 +1,2 @@
<%= t 'oauth2_authorized', 'Your application has been authorized for access to your Canvas data. Your session should begin automatically.' %>
<%# normally the native application will intercept the redirect and this will never be seen, this is just a failsafe %>
<%= t 'oauth2_complete', 'The application has finished the login workflow, and should reactivate shortly.' %>

View File

@ -0,0 +1,24 @@
<%
jammit_css :login
@headers = false
@body_classes << "modal"
content_for :page_title, t(:page_title, "App Login")
%>
<div id="modal-box-top"></div>
<div id="modal-box-arbitrary-size" class="modal-box-more-padding">
<h2><%= @app_name %></h2>
<p class="modal-box-content">
<%= mt 'details.allow_application', "%{app_name} is requesting access to your account.", :app_name => @app_name %>
<br/><br/>
<%= mt 'details.login_name', "You are logging into this app as %{user_name}.", :user_name => link_to(@current_user.short_name, profile_url, :popup => true) %>
<% if @current_user.email.present? && @current_user.email != @current_user.short_name %>
<br/>
<%= t 'details.email', "Your email address is %{email}.", :email => @current_user.email %>
<% end %>
</p>
<div class="button_box">
<%= link_to(t(:cancel, "Cancel"), oauth2_auth_deny_path, :class => "button") %>
<%= link_to(t(:log_in, "Login"), oauth2_auth_accept_path, :method => :post, :class => "button button-default") %>
</div>
</div>
<div id="modal-box-bottom"></div>

View File

@ -831,7 +831,10 @@ ActionController::Routing::Routes.draw do |map|
map.api_v1_files_create 'files_api', :controller => 'files', :action => 'api_create', :conditions => { :method => :post }
map.oauth2_auth 'login/oauth2/auth', :controller => 'pseudonym_sessions', :action => 'oauth2_auth', :conditions => { :method => :get }
map.oauth2_token 'login/oauth2/token',:controller => 'pseudonym_sessions', :action => 'oauth2_token', :conditions => { :method => :post }
map.oauth2_token 'login/oauth2/token', :controller => 'pseudonym_sessions', :action => 'oauth2_token', :conditions => { :method => :post }
map.oauth2_auth_confirm 'login/oauth2/confirm', :controller => 'pseudonym_sessions', :action => 'oauth2_confirm', :conditions => { :method => :get }
map.oauth2_auth_accept 'login/oauth2/accept', :controller => 'pseudonym_sessions', :action => 'oauth2_accept', :conditions => { :method => :post }
map.oauth2_auth_deny 'login/oauth2/deny', :controller => 'pseudonym_sessions', :action => 'oauth2_deny', :conditions => { :method => :get }
ApiRouteSet.route(map, "/api/lti/v1") do |lti|
lti.post "tools/:tool_id/grade_passback", :controller => :lti_api, :action => :grade_passback, :path_name => "lti_grade_passback_api"

View File

@ -111,6 +111,16 @@ response:
The app can then extract the code, and use it along with the
client_id and client_secret to obtain the final access_key.
If the user doesn't accept the request for access, or if another error
occurs, Canvas redirects back to your request\_uri with an `error`
parameter, rather than a `code` parameter, in the query string.
<div class="method_details">
<h3>http://www.example.com/oauth2response?error=access_denied</h3>
</div>
`access_denied` is the only currently implemented error code.
### Step 3: Exchange the code for the final access token
<div class="method_details">
@ -226,6 +236,16 @@ changed to contain <code>code=&lt;code&gt;</code> somewhere in the query
string. The app can then extract the code, and use it along with the
client_id and client_secret to obtain the final access_key.
If the user doesn't accept the request for access, or if another error
occurs, Canvas will add an `error`
parameter, rather than a `code` parameter, to the query string.
<div class="method_details">
<h3>/login/oauth2/auth?error=access_denied</h3>
</div>
`access_denied` is the only currently implemented error code.
### Step 3: Exchange the code for the final access token
<div class="method_details">

View File

@ -137,21 +137,22 @@ describe "API Authentication", :type => :integration do
# step 1
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
response.should redirect_to(login_url(:re_login => true))
response.should redirect_to(login_url)
yield
# step 2
# step 3
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/auth?})
response['Location'].should match(%r{/login/oauth2/confirm$})
get response['Location']
response.should render_template("pseudonym_sessions/oauth2_confirm")
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/auth\?})
code = response['Location'].match(/code=([^\?&]+)/)[1]
code.should be_present
# make sure the user is now logged out, or the app also has full access to their session
get '/'
response.should be_redirect
response['Location'].should == 'http://www.example.com/login'
# we have the code, we can close the browser session
if opts[:basic_auth]
post "/login/oauth2/token", { :code => code }, { :authorization => ActionController::HttpAuthentication::Basic.encode_credentials(@client_id, @client_secret) }
@ -241,11 +242,45 @@ describe "API Authentication", :type => :integration do
get '/login', :ticket => 'ST-abcd'
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/auth\?code=})
session.should be_blank
end
end
it "should not require logging in again, or log out afterwards" do
course_with_student_logged_in(:active_all => true, :user => user_with_pseudonym)
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/confirm$})
get response['Location']
response.should render_template("pseudonym_sessions/oauth2_confirm")
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/auth\?})
code = response['Location'].match(/code=([^\?&]+)/)[1]
code.should be_present
get response['Location']
response.should be_success
# verify we're still logged in
get "/courses/#{@course.id}"
response.should be_success
end
it "should redirect with access_denied if the user doesn't accept" do
course_with_student_logged_in(:active_all => true, :user => user_with_pseudonym)
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/confirm$})
get response['Location']
response.should render_template("pseudonym_sessions/oauth2_confirm")
get "/login/oauth2/deny"
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/auth\?})
error = response['Location'].match(%r{error=([^\?&]+)})[1]
error.should == "access_denied"
response['Location'].should_not match(%r{code=})
get response['Location']
response.should be_success
end
it "should allow http basic auth for the app auth" do
flow(:basic_auth => true) do
get response['Location']
@ -257,7 +292,7 @@ describe "API Authentication", :type => :integration do
it "should require the correct client secret" do
# step 1
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
response.should redirect_to(login_url(:re_login => true))
response.should redirect_to(login_url)
get response['Location']
response.should be_success
@ -268,7 +303,9 @@ describe "API Authentication", :type => :integration do
# step 2
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/auth?})
response['Location'].should match(%r{/login/oauth2/confirm$})
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
code = response['Location'].match(/code=([^\?&]+)/)[1]
code.should be_present
@ -304,12 +341,17 @@ describe "API Authentication", :type => :integration do
@key.update_attribute :redirect_uri, 'http://www.example.com/oauth2response'
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => "http://www.example.com/my_uri"
response.should redirect_to(login_url(:re_login => true))
response.should redirect_to(login_url)
get response['Location']
response.should be_success
post "/login", :pseudonym_session => { :unique_id => 'test1@example.com', :password => 'test123' }
response.should be_redirect
response['Location'].should match(%r{/login/oauth2/confirm$})
get response['Location']
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
response.should be_redirect
response['Location'].should match(%r{http://www.example.com/my_uri?})
code = response['Location'].match(/code=([^\?&]+)/)[1]

View File

@ -0,0 +1,51 @@
require File.expand_path(File.dirname(__FILE__) + '/common')
if Canvas.redis_enabled?
describe "oauth2 flow" do
it_should_behave_like "in-process server selenium tests"
before do
@key = DeveloperKey.create!(:name => 'Specs')
@client_id = @key.id
@client_secret = @key.api_key
end
describe "a logged-in user" do
before do
course_with_student_logged_in(:active_all => true)
end
it "should show the confirmation dialog without requiring login" do
get "/login/oauth2/auth?response_type=code&client_id=#{@client_id}&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
f('#modal-box-arbitrary-size').text.should match(%r{Specs is requesting access to your account})
expect_new_page_load { f('#modal-box-arbitrary-size a.button-default').click() }
driver.current_url.should match(%r{/login/oauth2/auth\?})
code = driver.current_url.match(%r{code=([^\?&]+)})[1]
code.should be_present
end
end
describe "a non-logged-in user" do
before do
course_with_student(:active_all => true, :user => user_with_pseudonym)
end
it "should show the confirmation dialog after logging in" do
get "/login/oauth2/auth?response_type=code&client_id=#{@client_id}&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
driver.current_url.should match(%r{/login$})
user_element = driver.find_element(:css, '#pseudonym_session_unique_id')
user_element.send_keys("nobody@example.com")
password_element = driver.find_element(:css, '#pseudonym_session_password')
password_element.send_keys("asdfasdf")
password_element.submit
f('#modal-box-arbitrary-size').text.should match(%r{Specs is requesting access to your account})
expect_new_page_load { f('#modal-box-arbitrary-size a.button-default').click() }
driver.current_url.should match(%r{/login/oauth2/auth\?})
code = driver.current_url.match(%r{code=([^\?&]+)})[1]
code.should be_present
end
end
end
end