diff --git a/app/controllers/concerns/dynamic_registration_service.rb b/app/controllers/concerns/dynamic_registration_service.rb new file mode 100644 index 00000000..7e33b30a --- /dev/null +++ b/app/controllers/concerns/dynamic_registration_service.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. + +# Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below). + +# This program is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 3.0 of the License, or (at your option) any later +# version. + +# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License along +# with BigBlueButton; if not, see . + +module DynamicRegistrationService + include ActiveSupport::Concern + + def client_registration_request_header(token) + { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': "Bearer #{token}", + } + end + + def client_registration_request_body(key_token) + params[:app] ||= params[:custom_broker_app] || Rails.configuration.default_tool + return if params[:app] == 'default' || params[:custom_broker_app] == 'default' + + jwks_uri = dynamic_registration_pubkeyset_url(key_token: key_token) + + tool = Rails.configuration.default_tool + + { + "application_type": 'web', + "response_types": ['id_token'], + "grant_types": %w[implict client_credentials], + "initiate_login_uri": openid_login_url(protocol: 'https'), + "redirect_uris": + [openid_launch_url(protocol: 'https'), + deep_link_request_launch_url(protocol: 'https'),], + "client_name": t("apps.#{tool}.title"), + "jwks_uri": jwks_uri, + # "logo_uri": 'https://client.example.org/logo.png', + # "policy_uri": 'https://client.example.org/privacy', + # "policy_uri#ja": 'https://client.example.org/privacy?lang=ja', + # "tos_uri": 'https://client.example.org/tos', + # "tos_uri#ja": 'https://client.example.org/tos?lang=ja', + "token_endpoint_auth_method": 'private_key_jwt', + # "contacts": ['ve7jtb@example.org', 'mary@example.org'], + "scope": 'https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', + "https://purl.imsglobal.org/spec/lti-tool-configuration": { + "domain": URI.parse(openid_launch_url(protocol: 'https')).host, + "description": t("apps.#{tool}.description"), + "target_link_uri": openid_launch_url(protocol: 'https'), + "custom_parameters": {}, + "claims": %w[iss sub name given_name family_name email], + "messages": [ + { + "type": 'LtiDeepLinkingRequest', + "target_link_uri": deep_link_request_launch_url(protocol: 'https'), + "label": 'Add a tool', + }, + ], + }, + } + end + + def dynamic_registration_resource(url, title, custom_params = {}) + { + 'type' => 'ltiResourceLink', + 'title' => title, + 'url' => url, + 'presentation' => { + 'documentTarget' => 'window', + }, + 'custom' => custom_params, + } + end + + def dynamic_registration_jwt_response(registration, jwt_header, jwt_body, resources) + message = { + 'iss' => registration['client_id'], + 'aud' => [registration['issuer']], + 'exp' => Time.now.to_i + 600, + 'iat' => Time.now.to_i, + 'nonce' => "nonce#{SecureRandom.hex}", + 'https://purl.imsglobal.org/spec/lti/claim/deployment_id' => jwt_body['https://purl.imsglobal.org/spec/lti/claim/deployment_id'], + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => 'LtiDeepLinkingResponse', + 'https://purl.imsglobal.org/spec/lti/claim/version' => '1.3.0', + 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' => resources, + 'https://purl.imsglobal.org/spec/lti-dl/claim/data' => jwt_body['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings']['data'], + } + + message.each do |key, value| + message[key] = '' if value.nil? + end + + priv = File.read(registration['tool_private_key']) + priv_key = OpenSSL::PKey::RSA.new(priv) + + JWT.encode(message, priv_key, 'RS256', kid: jwt_header['kid']) + end + + def validate_registration_initiation_request + # openid_configuration: the endpoint to the open id configuration to be used for this registration, encoded as per [RFC3986] Section 3.4. + raise CustomError, :openid_configuration_not_found unless params.key?('openid_configuration') + # registration_token (optional): the registration access token. If present, it must be used as the access token by the tool when making + # the registration request to the registration endpoint exposed in the openid configuration. + raise CustomError, :registration_token_not_found unless params.key?('registration_token') + + begin + jwt_parts = validate_jwt_format + jwt_header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0])) + jwt_body = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1])) + + logger.debug("jwt.header:\n#{jwt_header.inspect}") + logger.debug("jwt.body:\n#{jwt_body.inspect}") + rescue StandardError + raise CustomError, :jwt_error + end + + { + header: jwt_header, + body: jwt_body, + } + end + + # Generate a new RSA key pair and returnss the key_token as a reference. + def new_rsa_keypair + # Setting keys + private_key = OpenSSL::PKey::RSA.generate(4096) + public_key = private_key.public_key + + key_token = Digest::MD5.hexdigest(SecureRandom.uuid) + Dir.mkdir('.ssh/') unless Dir.exist?('.ssh/') + Dir.mkdir(".ssh/#{key_token}") unless Dir.exist?(".ssh/#{key_token}") + + File.open(Rails.root.join(".ssh/#{key_token}/priv_key"), 'w') do |f| + f.puts(private_key.to_s) + end + + File.open(Rails.root.join(".ssh/#{key_token}/pub_key"), 'w') do |f| + f.puts(public_key.to_s) + end + + key_token + end + + private + + def validate_jwt_format + jwt_parts = params[:registration_token].split('.') + raise CustomError, :invalid_id_token unless jwt_parts.length == 3 + + jwt_parts + end +end diff --git a/app/controllers/concerns/open_id_authenticator.rb b/app/controllers/concerns/open_id_authenticator.rb index d9d5612a..9ef5bb01 100644 --- a/app/controllers/concerns/open_id_authenticator.rb +++ b/app/controllers/concerns/open_id_authenticator.rb @@ -23,7 +23,7 @@ module OpenIdAuthenticator include ExceptionHandler def verify_openid_launch - validate_openid_message_state + validate_openid_message_state unless params.key?('registration_token') jwt_parts = validate_jwt_format jwt_header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0])) diff --git a/app/controllers/message_controller.rb b/app/controllers/message_controller.rb index e2ee4ff2..198f4d84 100644 --- a/app/controllers/message_controller.rb +++ b/app/controllers/message_controller.rb @@ -37,7 +37,7 @@ class MessageController < ApplicationController before_action :lti_authorized_application, only: %i[basic_lti_launch_request basic_lti_launch_request_legacy] # validates message with oauth in rails lti2 provider gem before_action :lti_authentication, only: %i[basic_lti_launch_request basic_lti_launch_request_legacy] - # validates message corresponds to a LTI launch + # validates message corresponds to a LTI request before_action :process_openid_message, only: %i[openid_launch_request deep_link] # fails lti_authentication in rails lti2 provider gem @@ -141,7 +141,7 @@ def content_item_selection def openid_launch_request ## The launch for LTI 1.3 sets params[:app] and redirectos to the corresponding app. The default tool is assigned if the parameter is not included. params[:app] ||= params[:custom_broker_app] || Rails.configuration.default_tool - return if params[:app] == 'default' || params[:broker_custom_app] == 'default' + return if params[:app] == 'default' || params[:custom_broker_app] == 'default' params[:oauth_nonce] = @jwt_body['nonce'] params[:oauth_consumer_key] = @jwt_body['iss'] diff --git a/app/controllers/registration_controller.rb b/app/controllers/registration_controller.rb new file mode 100644 index 00000000..ab34b377 --- /dev/null +++ b/app/controllers/registration_controller.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. + +# Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below). + +# This program is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 3.0 of the License, or (at your option) any later +# version. + +# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License along +# with BigBlueButton; if not, see . + +# Used to validate oauth signatures +require 'oauth/request_proxy/action_controller_request' + +class RegistrationController < ApplicationController + include RailsLti2Provider::ControllerHelpers + include ExceptionHandler + include OpenIdAuthenticator + include AppsValidator + include LtiHelper + include PlatformValidator + include DynamicRegistrationService + + before_action :print_parameters if Rails.configuration.developer_mode_enabled + # skip rail default verify auth token - we use our own strategies + skip_before_action :verify_authenticity_token + # validates message corresponds to a LTI request + before_action :process_registration_initiation_request, only: %i[dynamic] + + @error_message = '' + @error_suggestion = '' + + rescue_from ExceptionHandler::CustomError do |ex| + @error = case ex.error + when :tenant_not_found, :tool_duplicated + { code: '406', + key: t('error.http._406.code'), + message: @error_message, + suggestion: @error_suggestion || '', + status: '406', } + else + { code: '520', + key: t('error.http._520.code'), + message: t('error.http._520.message'), + suggestion: t('error.http._520.suggestion'), + status: '520', } + end + logger.error("Registration error:\n#{@error.to_yaml}") + render 'errors/index' + end + + def dynamic + # 3.7 Step 4: Registration Completed and Activation + # Once the registration is completed, successfully or not, the tool should notify the platform by sending an HTML5 Web Message + # [webmessaging] indicating the window may be closed. Depending on whether the platform opened the registration in an IFrame or + # a new tab, either window.parent or window.opener should be called. + end + + def pub_keyset + # The param :key_token is required. It should fail if not included. IT should also fail if not found. + key_token = params[:key_token] + tool_public_key = Rails.root.join(".ssh/#{key_token}/pub_key") + pub = File.read(tool_public_key) + pub_key = OpenSSL::PKey::RSA.new(pub) + + # lookup for the kid + tool = RailsLti2Provider::Tool.where('tool_settings LIKE ?', "%#{key_token}%").first + tool_settings = JSON.parse(tool.tool_settings) + jwt_parts = tool_settings['registration_token'].split('.') + jwt_header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0])) + + # prepare the pub_keyset + json_pub_keyset = {} + json_pub_keyset['keys'] = [ + { + kty: 'RSA', + e: Base64.urlsafe_encode64(pub_key.e.to_s(2)).delete('='), # Exponent + n: Base64.urlsafe_encode64(pub_key.n.to_s(2)).delete('='), # Modulus + kid: jwt_header['kid'], + alg: 'RS256', + use: 'sig', + }, + ] + + render(json: JSON.pretty_generate(json_pub_keyset)) + end + + private + + # verify lti 1.3 dynamic registration request + def process_registration_initiation_request + # Step 0: Validate the existent of the tenant + # TODO: There should be a onetime code that allows registration under specific tenant. + # for now on first registration, the tool is linked to the 'default' tenant, which must exist. + tenant_uid = '' + # only works if the targeted tenant exists. By default it will lookup for uid='' + unless RailsLti2Provider::Tenant.exists?(uid: tenant_uid) + @error_message = "Tenant with uid = '#{tenant_uid}' does not exist" + raise CustomError, :tenant_not_found + end + + tenant = RailsLti2Provider::Tenant.find_by(uid: tenant_uid) + + # 3.3 Step 1: Registration Initiation Request + begin + jwt = validate_registration_initiation_request + @jwt_header = jwt[:header] + @jwt_body = jwt[:body] + rescue StandardError => e + @error_message = "Error in registrtion initiation request verification: #{e}" + raise CustomError, :registration_verification_failed + end + + # 3.4 Step 2: Discovery and openid Configuration + openid_configuration = discover_openid_configuration(params['openid_configuration']) + + # scope can be @jwt_body['scope'] == 'reg' or @jwt_body['scope'] == 'reg-update' + if RailsLti2Provider::Tool.exists?(uuid: openid_configuration['issuer'], tenant: tenant) && @jwt_body['scope'] == 'reg' + @error_message = "Issuer or Platform ID has already been registered for tenant '#{tenant.uid}'" + raise CustomError, :tool_duplicated + end + + # 3.5 Step 3: Client Registration + uri = URI(openid_configuration['registration_endpoint']) + # 3.5.1 Issuer and OpenID Configuration URL Match + # validate_issuer(jwt_body) + + # 3.5.2 Client Registration Request + # TODO: old keys should be removed when @jwt_body['scope'] == 'reg-update' + key_token = new_rsa_keypair + header = client_registration_request_header(params[:registration_token]) + body = client_registration_request_body(key_token) + body = body.to_json + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') + request = Net::HTTP::Post.new(uri, header) + request.body = body + + response = http.request(request) + response = JSON.parse(response.body) + + # 3.6 Client Registration Response + reg = { + issuer: openid_configuration['issuer'], + client_id: response['client_id'], + key_set_url: openid_configuration['jwks_uri'], + auth_token_url: openid_configuration['token_endpoint'], + auth_login_url: openid_configuration['authorization_endpoint'], + tool_private_key: Rails.root.join(".ssh/#{key_token}/priv_key"), + registration_token: params[:registration_token], + } + + tool = RailsLti2Provider::Tool.find_or_create_by(uuid: openid_configuration['issuer'], tenant: tenant) + tool.shared_secret = response['client_id'] + tool.tool_settings = reg.to_json + tool.lti_version = '1.3.0' + tool.status = 'enabled' + tool.save + # 3.6.2 Client Registration Error Response + + # 3.6.1 Successful Registration + logger.debug(tool.to_yaml) + end + + def discover_openid_configuration(url) + JSON.parse(URI.parse(url).read) + end +end diff --git a/app/controllers/tool_profile_controller.rb b/app/controllers/tool_profile_controller.rb index 95ddd3c8..61187b64 100644 --- a/app/controllers/tool_profile_controller.rb +++ b/app/controllers/tool_profile_controller.rb @@ -68,7 +68,7 @@ def json_config @json_config['public_jwk'] = jwk @json_config['extensions'][0]['settings']['domain'] = request.base_url - @json_config['extensions'][0]['settings']['tool_id'] = Digest::MD5.hexdigest(request.base_url) + @json_config['extensions'][0]['settings']['tool_id'] = Digest::MD5.hexdigest(SecureRandom.uuid) @json_config['extensions'][0]['settings']['icon_url'] = lti_icon(params[:app]) @json_config['extensions'][0]['settings']['placements'].each do |placement| diff --git a/app/views/registration/dynamic.html.erb b/app/views/registration/dynamic.html.erb new file mode 100644 index 00000000..12330a32 --- /dev/null +++ b/app/views/registration/dynamic.html.erb @@ -0,0 +1,21 @@ +<%# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. + +Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below). + +This program is free software; you can redistribute it and/or modify it under the +terms of the GNU Lesser General Public License as published by the Free Software +Foundation; either version 3.0 of the License, or (at your option) any later +version. + +BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License along +with BigBlueButton; if not, see . %> + +<%= render "layouts/header_iframe" %> + +

Dynamic registration

+ +<%= render "layouts/footer_iframe" %> diff --git a/config/locales/en.yml b/config/locales/en.yml index fc52abd6..6982b311 100755 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,7 +41,7 @@ en: copypaste: "Paste this url into your tool consumer to register this tool:" apps: default: - title: "LTI Tool Provider" + title: "Default" description: "General Tool Provider that allows users to test LTI 1.x and 2.x capabilities" rooms: title: "Rooms" @@ -94,6 +94,10 @@ en: message: "Unauthorized access" suggestion: "If you are using this room through LTI try launching the app again by using the link or refreshing the browser" _406: + code: "Not Acceptable" + message: "This request cannot be processed" + suggestion: "Contact Tool Administrator." + _406.1: code: "Invalid Authenticity Token" message: "Can't verify CSRF token authenticity" suggestion: "You may be facing this error because the plugin is not SSL secured, or because third party cookies are not enabled." diff --git a/config/routes.rb b/config/routes.rb index 32f613c2..af0eb96b 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -69,6 +69,9 @@ post 'tool/messages/content-item', to: 'message#basic_lti_launch_request', as: 'content_item_launch' post 'tool/messages/deep-link', to: 'message#deep_link', as: 'deep_link_request_launch' post 'tool/messages/signed_content_item_request', to: 'message#signed_content_item_request' + # dynamic registration go through this paths + get 'tool/registration', to: 'registration#dynamic', as: :dynamic_registration + get 'tool/registration/pubkeyset/(:key_token)', to: 'registration#pub_keyset', as: :dynamic_registration_pubkeyset match 'tool/json_config/:temp_key_token', to: 'tool_profile#json_config', via: [:get, :post], as: 'json_config' # , :defaults => {:format => 'json'} diff --git a/lib/tasks/registration.rake b/lib/tasks/registration.rake index cd41aace..920c7de1 100644 --- a/lib/tasks/registration.rake +++ b/lib/tasks/registration.rake @@ -59,12 +59,8 @@ namespace :registration do private_key = OpenSSL::PKey::RSA.generate(4096) public_key = private_key.public_key - jwk = JWT::JWK.new(private_key).export - jwk['alg'] = 'RS256' unless jwk.key?('alg') - jwk['use'] = 'sig' unless jwk.key?('use') - jwk = jwk.to_json - key_dir = Digest::MD5.hexdigest(issuer + client_id) + key_dir = Digest::MD5.hexdigest(SecureRandom.uuid) Dir.mkdir('.ssh/') unless Dir.exist?('.ssh/') Dir.mkdir(".ssh/#{key_dir}") unless Dir.exist?(".ssh/#{key_dir}") @@ -76,6 +72,12 @@ namespace :registration do f.puts(public_key.to_s) end + # Only required for the output for cases when the private key is required as a token. + jwk = JWT::JWK.new(private_key).export + jwk['alg'] = 'RS256' unless jwk.key?('alg') + jwk['use'] = 'sig' unless jwk.key?('use') + jwk = jwk.to_json + reg = { issuer: issuer, client_id: client_id, @@ -265,7 +267,7 @@ namespace :registration do jwk['use'] = 'sig' unless jwk.key?('use') jwk = jwk.to_json - key_dir = Digest::MD5.hexdigest(issuer + client_id) + key_dir = Digest::MD5.hexdigest(SecureRandom.uuid) Dir.mkdir('.ssh/') unless Dir.exist?('.ssh/') Dir.mkdir(".ssh/#{key_dir}") unless Dir.exist?(".ssh/#{key_dir}")