Skip to content

Commit

Permalink
Get authentication, class membership, and roles via LTI (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
cbothner authored Apr 19, 2017
2 parents 20028a9 + 5f4e1c5 commit 75b4cab
Show file tree
Hide file tree
Showing 31 changed files with 381 additions and 45 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ node_modules/
app/assets/javascripts/react
/public/packs
/node_modules

localhost.crt
localhost.key
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ group :development do
gem 'letter_opener'
gem 'web-console'
gem 'bullet'
gem 'awesome_print'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ GEM
arel (7.1.1)
authority (3.2.0)
activesupport (>= 3.0.0)
awesome_print (1.7.0)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
Expand Down Expand Up @@ -376,6 +377,7 @@ DEPENDENCIES
acts_as_list
ahoy_matey
authority
awesome_print
bourbon
bullet
byebug
Expand Down
2 changes: 1 addition & 1 deletion Procfile.dev
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
web: bin/rails s -p3000 -b0.0.0.0
web: [ -z "$LOCALHOST_SSL" ] && bin/rails s -p3000 -b0.0.0.0 || bundle exec puma -b 'ssl://0.0.0.0:3000?key=/vagrant/localhost.key&cert=/vagrant/localhost.crt'
webpack: bin/webpack-dev-server
10 changes: 10 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ can `vagrant plugin install vagrant-fsnotify`), and then
2. In one terminal window or tmux pane: `vagrant ssh -c 'cd /vagrant && foreman start'`.
6. In another, `vagrant fsnotify`
6. Browse to http://localhost:3000/

## https://localhost

When developing the LTI tool provider components of Gala, it is useful to be able to use https with the development server. This is how to set that up.

1. Do this in the vagrant instance. Generate a self-signed certificate: `openssl req -new -newkey rsa:2048 -sha1 -days 365 -nodes -x509 -keyout localhost.key -out localhost.crt`
2. Trust the certificate in your vagrant instance: `sudo cp localhost.crt /etc/ssl/cert && sudo cp localhost.key /etc/ssl/private && sudo c_rehash`
3. Trust the certificate on your host machine using Keychain Access. Drag `localhost.crt` into the app, then Get Info and choose Always Trust.
4. Start the development servers with `LOCALHOST_SSL=true foreman start`
6. Browse to https://localhost:3000 (http will not work)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AuthenticationStrategies::ConfigController < ApplicationController

def lti
end

end
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
class AuthenticationStrategies::OmniauthCallbacksController < Devise::OmniauthCallbacksController
before_action :set_authentication_strategy, except: [:failure]
before_action :set_reader, except: [:failure]
before_action :set_case, only: [:lti]
before_action :set_group, only: [:lti]

def google
if @authentication_strategy.persisted?
sign_in_and_redirect @authentication_strategy.reader, event: :authentication
sign_in_and_redirect @reader, event: :authentication
else
session["devise.google_data"] = request.env["omniauth.auth"].except(:extra)
render 'devise/registrations/new', layout: "window"
Expand All @@ -12,7 +15,10 @@ def google

def lti
if @authentication_strategy.persisted?
sign_in_and_redirect @authentication_strategy.reader, event: :authentication
sign_in @reader
add_reader_to_group
enroll_reader_in_case if @case
redirect_to redirect_url
else
session["devise.lti_data"] = request.env["omniauth.auth"]
render 'devise/registrations/new', layout: "window"
Expand All @@ -28,4 +34,40 @@ def set_authentication_strategy
@authentication_strategy = AuthenticationStrategy.from_omniauth(request.env["omniauth.auth"])
end

def set_reader
@reader = @authentication_strategy.reader
end

def set_case
@case = Case.find_by_slug params[:case_slug]
end

def set_group
begin
@group = Group.upsert context_id: params[:context_id], name: params[:context_title]
rescue
retry
end
end

def add_reader_to_group
unless @reader.group_memberships.exists? group: @group
@reader.group_memberships.create group: @group
end
end

def enroll_reader_in_case
Enrollment.upsert reader_id: @reader.id,
case_id: @case.id,
status: Enrollment.status_from_lti_role(params[:ext_roles])
end

def redirect_url
if @case
case_url @case
else
root_path
end
end

end
2 changes: 1 addition & 1 deletion app/controllers/cases_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def show
authenticate_reader! unless @case.published
authorize_action_for @case

render layout: 'application'
render layout: 'with_header'
end

def new
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/catalog_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,13 @@ def home
@index = cases_in_catalog.select(&:in_index?).sort_by &:kicker
render layout: "window"
end

# LTI Assignment Selection wants to POST a ContentItemSelectionRequest
def content_items
I18n.locale = params[:launch_presentation_locale]
@items = Case.where(published: true).sort_by(&:kicker)
@return_url = params[:content_item_return_url]
@data = params[:data]
render layout: "embed"
end
end
79 changes: 79 additions & 0 deletions app/javascript/content_items/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @flow

import React from 'react'
import { chooseContentItem } from 'shared/lti'

type ContentItem = {|
kicker: string,
title: string,
dek: string,
coverUrl: string,
url: string,
|}

type ContentItemsProps = {|
items: ContentItem[],
returnUrl: string,
returnData: string,
|}

const ContentItems = ({ items, returnUrl, returnData }: ContentItemsProps) => {
const handleChooseContentItem = chooseContentItem.bind(
undefined,
returnUrl,
returnData
)
return (
<div className="catalog-cases">
<div className="catalog-cases-index">
{items.map((item: ContentItem, i: number) => (
<ContentItemLink
key={i}
{...item}
handleChooseContentItem={handleChooseContentItem}
/>
))}
</div>
</div>
)
}

export default ContentItems

type ContentItemProps = ContentItem & {|
handleChooseContentItem: (string) => void,
|}
const ContentItemLink = (
{
kicker,
title,
dek,
coverUrl,
url,
handleChooseContentItem,
}: ContentItemProps
) => {
const handleClick = handleChooseContentItem.bind(undefined, url)
return (
<a
tabIndex="0"
className="BillboardTitle catalog-case catalog-content-item"
style={{
backgroundImage: `
linear-gradient(rgba(0, 0, 0, 0.0), rgba(0, 0, 0, 0.5)),
url(${coverUrl})`,
}}
onClick={handleClick}
>
<div className="catalog-case-credits">
<h2>
<span className="c-kicker">{kicker}</span>
{title}
</h2>
<p style={{ display: 'none' }}>
{dek}
</p>
</div>
</a>
)
}
23 changes: 23 additions & 0 deletions app/javascript/packs/content_items.entry.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @flow

import React from 'react'
import ReactDOM from 'react-dom'

import ContentItems from 'content_items'

function render (Component: React$Component) {
const container = document.getElementById('content-items-app')

if (container == null) return

ReactDOM.render(
<Component
items={JSON.parse(container.getAttribute('data-items'))}
returnUrl={container.getAttribute('data-return-url')}
returnData={container.getAttribute('data-return-data')}
/>,
container
)
}

render(ContentItems)
57 changes: 57 additions & 0 deletions app/javascript/shared/lti.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @flow

export function chooseContentItem (
returnUrl: string,
returnData: string,
itemUrl: string
): void {
submitForm(returnUrl, contentItemSelectionMessageData(returnData, itemUrl))
}

function contentItemSelectionMessageData (
returnData: string,
itemUrl: string
): { [string]: string } {
return {
lti_message_type: 'ContentItemSelection',
lti_version: 'LTI-1p0',
data: returnData,
content_items: JSON.stringify({
'@context': 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem',
'@graph': [
{
'@type': 'LtiLinkItem',
url: itemUrl,
mediaType: 'application/vnd.ims.lti.v1.ltilink',
placementAdvice: {
presentationDocumentTarget: 'window',
},
},
],
}),
}
}

// Form Submission

function submitForm (action: string, data: { [string]: string }): void {
const form = document.createElement('form')
form.action = action
form.method = 'POST'

for (const field in data) {
form.appendChild(buildFormInput(field, data[field]))
}

document.body && document.body.appendChild(form)

form.submit()
}

function buildFormInput (name: string, value: string): HTMLInputElement {
const el = document.createElement('input')
el.type = 'hidden'
el.name = name
el.value = value
return el
}
14 changes: 11 additions & 3 deletions app/models/enrollment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ class Enrollment < ApplicationRecord

enum status: %i(student instructor treatment)

def upsert
enrollment = Self.find_or_initialize_by( case_id: case_id, reader_id: reader_id )
def self.upsert case_id:, reader_id:, status: student
enrollment = find_or_initialize_by( case_id: case_id, reader_id: reader_id )
enrollment.status = status
enrollment.save! unless enrollment.persisted?
enrollment.save! if enrollment.changed?
enrollment
end

def self.status_from_lti_role ext_roles
if ext_roles.match 'urn:lti:role:ims/lis/Instructor'
:instructor
else
:student
end
end

def as_json(options = {})
super(options.merge({include: [reader: { only: %i(id image_url initials name) }]}))
end
Expand Down
9 changes: 9 additions & 0 deletions app/models/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@ class Group < ApplicationRecord
has_many :readers, through: :group_memberships

translates :name

validates :context_id, uniqueness: true, if: -> () { context_id.present? }

def self.upsert context_id:, name:
group = find_or_initialize_by context_id: context_id
group.name = name
group.save! if group.changed?
group
end
end
27 changes: 27 additions & 0 deletions app/views/authentication_strategies/config/lti.xml.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd"
>
<blti:title>Michigan Sustainability Cases</blti:title>
<blti:description>Description of MSC</blti:description>
<blti:launch_url><%= authentication_strategy_lti_omniauth_callback_url %></blti:launch_url>
<blti:extensions platform="canvas.instructure.com">
<lticm:property name="oauth_compliant">true</lticm:property>
<lticm:property name="privacy_level">public</lticm:property>
<lticm:property name="domain">www.learnmsc.org</lticm:property>
<lticm:options name="course_navigation">
<lticm:property name="enabled">true</lticm:property>
</lticm:options>
<lticm:options name="assignment_selection">
<lticm:property name="message_type">ContentItemSelectionRequest</lticm:property>
<lticm:property name="url"><%= catalog_content_items_url %></lticm:property>
</lticm:options>
</blti:extensions>
</cartridge_basiclti_link>
5 changes: 5 additions & 0 deletions app/views/catalog/content_items.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#content-items-app{data: {return_data: @data,
return_url: @return_url,
items: render(template: 'catalog/content_items', formats: [:json])}}

= javascript_pack_tag 'content_items'
6 changes: 6 additions & 0 deletions app/views/catalog/content_items.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
json.key_format! camelize: :lower
json.array! @items do |kase|
json.extract! kase, *%i(kicker title dek)
json.cover_url ix_cover_image(kase, :small)
json.url authentication_strategy_lti_omniauth_callback_url case_slug: kase.slug
end
2 changes: 1 addition & 1 deletion app/views/layouts/admin.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
<%= yield %>
</div>

<% parent_layout 'application' %>
<% parent_layout 'with_header' %>
Loading

0 comments on commit 75b4cab

Please sign in to comment.