Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Added functionality to apply bonuses and penalties as a percentage of the student's earned marks to ExtraMark model (#7702)
- Switched to consistent Font Awesome chevrons for expander icons (#7713)
- Install Ruby-LSP to allow development inside different IDEs such as VSCode (#7718)
- Ensure only instructors and admins can link course, as LMS launch MarkUs button made available for all users (#7714)

### 🐛 Bug fixes
- Fix name column search in graders table (#7693)
Expand Down
20 changes: 17 additions & 3 deletions app/controllers/lti_deployments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ def redirect_login
deployment_id: lti_params[LtiDeployment::LTI_CLAIMS[:deployment_id]],
lms_course_name: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['title'],
lms_course_label: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['label'],
lms_course_id: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['course_id'] }
lms_course_id: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['course_id'],
user_roles: lti_params[LtiDeployment::LTI_CLAIMS[:roles]] }
if lti_params.key?(LtiDeployment::LTI_CLAIMS[:names_role])
name_and_roles_endpoint = lti_params[LtiDeployment::LTI_CLAIMS[:names_role]]['context_memberships_url']
lti_data[:names_role_service] = name_and_roles_endpoint
Expand Down Expand Up @@ -135,9 +136,18 @@ def redirect_login
LtiUser.find_or_create_by(user: @real_user, lti_client: lti_client,
lti_user_id: lti_data[:lti_user_id])
if lti_deployment.course.nil?
# Redirect to course picker page
redirect_to choose_course_lti_deployment_path(lti_deployment)
# Check if the user has any of the privileged roles
has_privileged_role = lti_data[:user_roles].any? do |role_uri|
LtiDeployment::LTI_PRIVILEGED_ROLES.include?(role_uri)
end
has_ta_role = lti_data[:user_roles].include?(LtiDeployment::LTI_ROLES[:ta])
if has_privileged_role && !has_ta_role
redirect_to choose_course_lti_deployment_path(lti_deployment)
else
redirect_to course_not_set_up_lti_deployment_path(lti_deployment)
end
else
# Course is linked, proceed to the course path
redirect_to course_path(lti_deployment.course)
end
ensure
Expand All @@ -150,6 +160,10 @@ def public_jwk
render json: { keys: [jwk.export] }
end

def course_not_set_up
render 'course_not_set_up', status: :not_found
end

def choose_course
@lti_deployment = record
if request.post?
Expand Down
12 changes: 10 additions & 2 deletions app/models/lti_deployment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@ class LtiDeployment < ApplicationRecord
names_role: 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice',
ags_lineitem: 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint',
deployment_id: 'https://purl.imsglobal.org/spec/lti/claim/deployment_id',
user_id: 'sub' }.freeze
user_id: 'sub',
roles: 'https://purl.imsglobal.org/spec/lti/claim/roles' }.freeze
LTI_ROLES = { learner: 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
test_user: 'http://purl.imsglobal.org/vocab/lti/system/person#TestUser',
ta: 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant',
instructor: 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor' }.freeze
instructor: 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
admin: 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator',
sysadmin: 'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin',
account_admin: 'http://purl.imsglobal.org/vocab/lis/v2/system/person#AccountAdmin' }.freeze
LTI_PRIVILEGED_ROLES = [LTI_ROLES[:instructor],
LTI_ROLES[:admin],
LTI_ROLES[:sysadmin],
LTI_ROLES[:account_admin]].freeze

# Gets a list of all users in the LMS course associated with this deployment
# with the learner role and creates roles and LTI IDs for each user.
Expand Down
4 changes: 4 additions & 0 deletions app/views/lti_deployments/course_not_set_up.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<section id='content'>
<h1><%= t('lti.course_not_found') %></h1>
<p><%= t('lti.course_not_set_up') %></p>
</section>
2 changes: 2 additions & 0 deletions config/locales/common/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ en:
course_exists: A course with this name already exists on MarkUs. Please select a course to link to.
course_link_error: Unsuccessful. Please relaunch MarkUs from your LMS.
course_link_success: Success. %{markus_course_name} is now linked with your LMS.
course_not_found: Course Not Found
course_not_linked: This course has not been linked with an external course. To link this course with an external course, launch MarkUs from the external LMS.
course_not_set_up: This course has not been linked to MarkUs yet.
course_select_instructions: Match your course with a MarkUs course.
create_course: Create New Course
create_line_item_checkbox: Create gradebook item?
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@
member do
get 'choose_course'
post 'choose_course'
get 'course_not_set_up'
post 'create_course'
end
end
Expand Down
100 changes: 77 additions & 23 deletions spec/support/lti_controller_examples.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@
root_uri.query = launch_params.to_query
root_uri.to_s
end
let(:mock_roles) { [LtiDeployment::LTI_ROLES[:instructor]] }

def create_pub_jwk
@create_pub_jwk ||= JWT::JWK.new(OpenSSL::PKey::RSA.new(1024))
end

def generate_payload(roles, nonce)
{ aud: client_id,
iss: host,
nonce: nonce,
LtiDeployment::LTI_CLAIMS[:deployment_id] => 'some_deployment_id',
LtiDeployment::LTI_CLAIMS[:context] => { label: 'csc108', title: 'test' },
LtiDeployment::LTI_CLAIMS[:custom] => { course_id: 1, user_id: 1 },
LtiDeployment::LTI_CLAIMS[:user_id] => 'some_user_id',
LtiDeployment::LTI_CLAIMS[:roles] => roles }
end

def generate_lti_jwt(roles, nonce)
payload = generate_payload(roles, nonce)
pub_jwk = create_pub_jwk
JWT.encode(payload, pub_jwk.keypair, 'RS256', { kid: pub_jwk.kid })
end

describe '#launch' do
context 'when launching with invalid parameters' do
let(:lti_message_hint) { 'opaque string' }
Expand Down Expand Up @@ -98,6 +121,8 @@
let(:nonce) { rand(10 ** 30).to_s.rjust(30, '0') }

before do
stub_request(:get, jwk_url).to_return(status: 200, body: { keys: [create_pub_jwk.export] }.to_json)

lti_launch_data = {}
lti_launch_data[:client_id] = client_id
lti_launch_data[:iss] = host
Expand Down Expand Up @@ -129,27 +154,10 @@
end

context 'with correct parameters' do
let(:payload) do
{ aud: client_id,
iss: host,
nonce: nonce,
LtiDeployment::LTI_CLAIMS[:deployment_id] => 'some_deployment_id',
LtiDeployment::LTI_CLAIMS[:context] => {
label: 'csc108',
title: 'test'
},
LtiDeployment::LTI_CLAIMS[:custom] => {
course_id: 1,
user_id: 1
},
LtiDeployment::LTI_CLAIMS[:user_id] => 'some_user_id' }
end
let(:pub_jwk) { JWT::JWK.new(OpenSSL::PKey::RSA.new(1024)) }
let(:lti_jwt) { JWT.encode(payload, pub_jwk.keypair, 'RS256', { kid: pub_jwk.kid }) }
let(:lti_jwt) { generate_lti_jwt(mock_roles, nonce) }

before do
session[:client_id] = client_id
stub_request(:get, jwk_url).to_return(status: 200, body: { keys: [pub_jwk.export] }.to_json)
end

it 'successfully decodes the jwt and redirects' do
Expand Down Expand Up @@ -190,6 +198,53 @@
end
end

context 'with LTI role authorization' do
let(:admin_lti_uri) { LtiDeployment::LTI_ROLES[:admin] }
let(:student_lti_uri) { LtiDeployment::LTI_ROLES[:learner] }
let(:ta_lti_uri) { LtiDeployment::LTI_ROLES[:ta] }
let(:instructor_lti_uri) { LtiDeployment::LTI_ROLES[:instructor] }

context 'when LTI role is Instructor' do
let(:lti_jwt) { generate_lti_jwt([instructor_lti_uri], nonce) }

it 'redirects to course chooser' do
request.headers['Referer'] = host
post_as instructor, :redirect_login, params: { state: session.id.to_s, id_token: lti_jwt }
expect(response).to redirect_to(choose_course_lti_deployment_path(LtiDeployment.first))
end
end

context 'when LTI role is Admin' do
let(:lti_jwt) { generate_lti_jwt([admin_lti_uri], nonce) }

it 'redirects to course chooser' do
request.headers['Referer'] = host
post_as instructor, :redirect_login, params: { state: session.id.to_s, id_token: lti_jwt }
expect(response).to redirect_to(choose_course_lti_deployment_path(LtiDeployment.first))
end
end

context 'when LTI role is Student' do
let(:lti_jwt) { generate_lti_jwt([student_lti_uri], nonce) }

it 'redirects to "not set up" page' do
request.headers['Referer'] = host
post_as instructor, :redirect_login, params: { state: session.id.to_s, id_token: lti_jwt }
expect(response).to redirect_to(course_not_set_up_lti_deployment_path(LtiDeployment.first))
end
end

context 'when LTI role is TA (even with Instructor claim)' do
let(:lti_jwt) { generate_lti_jwt([ta_lti_uri, instructor_lti_uri], nonce) }

it 'redirects to "not set up" page' do
request.headers['Referer'] = host
post_as instructor, :redirect_login, params: { state: session.id.to_s, id_token: lti_jwt }
expect(response).to redirect_to(course_not_set_up_lti_deployment_path(LtiDeployment.first))
end
end
end

context 'get' do
it 'returns an error if not logged in' do
request.headers['Referer'] = host
Expand All @@ -212,7 +267,8 @@
lms_course_name: 'Introduction to Computer Science',
lms_course_label: 'CSC108',
lms_course_id: 1,
lti_user_id: 'user_id' }
lti_user_id: 'user_id',
user_roles: mock_roles }
end
let(:payload) do
{ aud: client_id,
Expand All @@ -225,14 +281,12 @@
LtiDeployment::LTI_CLAIMS[:custom] => {
course_id: 1,
user_id: 1
} }
},
LtiDeployment::LTI_CLAIMS[:roles] => mock_roles }
end
let(:pub_jwk) { JWT::JWK.new(OpenSSL::PKey::RSA.new(1024)) }
let(:lti_jwt) { JWT.encode(payload, pub_jwk.keypair, 'RS256', { kid: pub_jwk.kid }) }

before do
cookies.permanent.encrypted[:lti_data] = { value: JSON.generate(lti_data), expires: 5.minutes.from_now }
stub_request(:get, jwk_url).to_return(status: 200, body: { keys: [pub_jwk.export] }.to_json)
end

it 'successfully decodes the jwt and redirects' do
Expand Down