From 4d62cdadd5876b323f5a386a2c41271ddcf32dfc Mon Sep 17 00:00:00 2001 From: Ahmad Farhat Date: Thu, 9 Nov 2023 12:13:23 -0500 Subject: [PATCH] Initial work for custom lrs integration (#1035) * Initial work for custom lrs integration * Added tests --- .rubocop.yml | 2 + .../bigbluebutton_api_controller.rb | 5 ++ app/models/tenant.rb | 16 ++++- app/services/lrs_payload_service.rb | 63 +++++++++++++++++++ lib/tasks/tenants.rake | 38 +++++++++++ spec/factories/tenant.rb | 6 ++ .../bigbluebutton_api_controller_spec.rb | 43 +++++++++++++ spec/services/lrs_payload_service_spec.rb | 44 +++++++++++++ 8 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 app/services/lrs_payload_service.rb create mode 100644 spec/services/lrs_payload_service_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index b17ad0f7..d939fb4b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -135,6 +135,7 @@ Metrics/PerceivedComplexity: Exclude: - app/models/recording.rb - app/models/server.rb + - app/models/tenant.rb - lib/server_sync.rb # Avoid classes longer than 100 lines of code. @@ -164,6 +165,7 @@ Metrics/CyclomaticComplexity: Exclude: - app/models/recording.rb - app/models/server.rb + - app/models/tenant.rb - lib/server_sync.rb # Checks for method parameter names that contain capital letters, end in numbers, or do not meet a minimal length. diff --git a/app/controllers/bigbluebutton_api_controller.rb b/app/controllers/bigbluebutton_api_controller.rb index 93c22837..5e2d0f13 100644 --- a/app/controllers/bigbluebutton_api_controller.rb +++ b/app/controllers/bigbluebutton_api_controller.rb @@ -198,6 +198,11 @@ def create params[:voiceBridge] = meeting.voice_bridge + if @tenant&.lrs_endpoint.present? + lrs_payload = LrsPayloadService.new(tenant: @tenant, secret: server.secret).call + params[:'meta_secret-lrs-payload'] = lrs_payload if lrs_payload.present? + end + logger.debug("Creating meeting #{params[:meetingID]} on BigBlueButton server #{server.id}") params_hash = params diff --git a/app/models/tenant.rb b/app/models/tenant.rb index 1fce1188..8ee40cdf 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -3,7 +3,7 @@ class Tenant < ApplicationRedisRecord SECRETS_SEPARATOR = ':' - define_attribute_methods :id, :name, :secrets + define_attribute_methods :id, :name, :secrets, :lrs_endpoint, :kc_token_url, :kc_client_id, :kc_client_secret, :kc_username, :kc_password # Unique ID for this tenant application_redis_attr :id @@ -14,6 +14,14 @@ class Tenant < ApplicationRedisRecord # Shared secrets for making API requests for this tenant (: separated) application_redis_attr :secrets + # Custom LRS work + application_redis_attr :lrs_endpoint + application_redis_attr :kc_token_url + application_redis_attr :kc_client_id + application_redis_attr :kc_client_secret + application_redis_attr :kc_username + application_redis_attr :kc_password + def save! with_connection do |redis| raise RecordNotSaved.new('Cannot update id field', self) if id_changed? && !@new_record @@ -34,6 +42,12 @@ def save! pipeline.del(old_names_key) if !id_changed? && name_changed? # Delete the old name key if it's not a new record and the name was updated pipeline.hset(id_key, 'name', name) if name_changed? pipeline.hset(id_key, 'secrets', secrets) if secrets_changed? + pipeline.hset(id_key, 'lrs_endpoint', lrs_endpoint) if lrs_endpoint_changed? + pipeline.hset(id_key, 'kc_token_url', kc_token_url) if kc_token_url_changed? + pipeline.hset(id_key, 'kc_client_id', kc_client_id) if kc_client_id_changed? + pipeline.hset(id_key, 'kc_client_secret', kc_client_secret) if kc_client_secret_changed? + pipeline.hset(id_key, 'kc_username', kc_username) if kc_username_changed? + pipeline.hset(id_key, 'kc_password', kc_password) if kc_password_changed? pipeline.sadd?('tenants', id) if id_changed? end end diff --git a/app/services/lrs_payload_service.rb b/app/services/lrs_payload_service.rb new file mode 100644 index 00000000..202e8099 --- /dev/null +++ b/app/services/lrs_payload_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class LrsPayloadService + def initialize(tenant:, secret:) + @tenant = tenant + @secret = secret + end + + def call + Rails.logger.debug { "Fetching LRS token from #{@tenant.kc_token_url}" } + + url = URI.parse(@tenant.kc_token_url) + http = Net::HTTP.new(url.host, url.port) + http.use_ssl = (url.scheme == 'https') + + payload = { + client_id: @tenant.kc_client_id, + client_secret: @tenant.kc_client_secret, + username: @tenant.kc_username, + password: @tenant.kc_password, + grant_type: 'password' + } + + request = Net::HTTP::Post.new(url.path) + request.set_form_data(payload) + + response = http.request(request) + + if response.code.to_i != 200 + Rails.logger.warn("Error #{response.message} when trying to fetch LRS Access Token") + return nil + end + + parsed_response = JSON.parse(response.body) + kc_access_token = parsed_response['access_token'] + + lrs_payload = { + lrs_endpoint: @tenant.lrs_endpoint, + lrs_token: kc_access_token + } + + # Generate a random salt + salt = SecureRandom.random_bytes(8) + + # Generate a key and initialization vector (IV) using PBKDF2 with SHA-256 + key_iv = OpenSSL::PKCS5.pbkdf2_hmac(@secret, salt, 10_000, 48, OpenSSL::Digest.new('SHA256')) + key = key_iv[0, 32] # 32 bytes for the key + iv = key_iv[32, 16] # 16 bytes for the IV + + # Encrypt the data using AES-256-CBC + cipher = OpenSSL::Cipher.new('AES-256-CBC') + cipher.encrypt + cipher.key = key + cipher.iv = iv + + # Encrypt and Base64 encode the data + Base64.strict_encode64(Random.random_bytes(8) + salt + cipher.update(lrs_payload.to_json) + cipher.final) + rescue StandardError => e + Rails.logger.warn("Error #{e} when trying to compute LRS Payload") + + nil + end +end diff --git a/lib/tasks/tenants.rake b/lib/tasks/tenants.rake index 6a10a277..5af6167b 100644 --- a/lib/tasks/tenants.rake +++ b/lib/tasks/tenants.rake @@ -14,6 +14,12 @@ task tenants: :environment do |_t, _args| puts("id: #{tenant.id}") puts("\tname: #{tenant.name}") puts("\tsecrets: #{tenant.secrets}") + puts("\tlrs_endpoint: #{tenant.lrs_endpoint}") if tenant.lrs_endpoint.present? + puts("\tkc_token_url: #{tenant.kc_token_url}") if tenant.kc_token_url.present? + puts("\tkc_client_id: #{tenant.kc_client_id}") if tenant.kc_client_id.present? + puts("\tkc_client_secret: #{tenant.kc_client_secret}") if tenant.kc_client_secret.present? + puts("\tkc_username: #{tenant.kc_username}") if tenant.kc_username.present? + puts("\tkc_password: #{tenant.kc_password}") if tenant.kc_password.present? end end @@ -53,6 +59,38 @@ namespace :tenants do tenant = Tenant.find(id) tenant.name = name if name.present? tenant.secrets = secrets if secrets.present? + + tenant.save! + + puts('OK') + puts("Updated Tenant id: #{tenant.id}") + end + + desc 'Update an existing Tenants LRS credentials' + task :update_lrs, [:id, :lrs_endpoint, :kc_token_url, :kc_client_id, :kc_client_secret, :kc_username, :kc_password] => :environment do |_t, args| + check_multitenancy + id = args[:id] + lrs_endpoint = args[:lrs_endpoint] + kc_token_url = args[:kc_token_url] + kc_client_id = args[:kc_client_id] + kc_client_secret = args[:kc_client_secret] + kc_username = args[:kc_username] + kc_password = args[:kc_password] + + if id.blank? || lrs_endpoint.blank? || kc_token_url.blank? || kc_client_id.blank? || + kc_client_secret.blank? || kc_username.blank? || kc_password.blank? + puts('Error: id and either name or secrets are required to update a Tenant') + exit(1) + end + + tenant = Tenant.find(id) + tenant.lrs_endpoint = lrs_endpoint + tenant.kc_token_url = kc_token_url + tenant.kc_client_id = kc_client_id + tenant.kc_client_secret = kc_client_secret + tenant.kc_username = kc_username + tenant.kc_password = kc_password + tenant.save! puts('OK') diff --git a/spec/factories/tenant.rb b/spec/factories/tenant.rb index dc795fea..15f43012 100644 --- a/spec/factories/tenant.rb +++ b/spec/factories/tenant.rb @@ -4,5 +4,11 @@ factory :tenant do name { Faker::Creature::Animal.name } secrets { "#{Faker::Crypto.sha256}:#{Faker::Crypto.sha512}" } + lrs_endpoint { nil } + kc_token_url { nil } + kc_client_id { nil } + kc_client_secret { nil } + kc_username { nil } + kc_password { nil } end end diff --git a/spec/requests/bigbluebutton_api_controller_spec.rb b/spec/requests/bigbluebutton_api_controller_spec.rb index 6f2ae5fa..7f769938 100644 --- a/spec/requests/bigbluebutton_api_controller_spec.rb +++ b/spec/requests/bigbluebutton_api_controller_spec.rb @@ -922,6 +922,49 @@ end end end + + context 'secret-lrs-payload' do + before do + tenant.lrs_endpoint = 'https://test.com' + + tenant.save! + end + + it 'makes a call to the LrsPayloadService and sets meta_secret-lrs-payload' do + allow_any_instance_of(LrsPayloadService).to receive(:call).and_return('test-token') + + create_params = { meetingID: "test-meeting-1", moderatorPW: "test-password", voiceBridge: "1234567" } + stub_params = { meetingID: "test-meeting-1", moderatorPW: "test-password", voiceBridge: "1234567", + 'meta_tenant-id': tenant.id, 'meta_secret-lrs-payload': 'test-token' } + + stub_create = stub_request(:get, encode_bbb_uri("create", server.url, server.secret, stub_params)) + .to_return(body: "SUCCESStest-meeting-1 + apmp") + + get bigbluebutton_api_create_url, params: create_params + + response_xml = Nokogiri.XML(response.body) + expect(stub_create).to have_been_requested + expect(response_xml.at_xpath("/response/returncode").text).to eq("SUCCESS") + end + + it 'does not set meta_secret-lrs-payload if the value is nil' do + allow_any_instance_of(LrsPayloadService).to receive(:call).and_return(nil) + + create_params = { meetingID: "test-meeting-1", moderatorPW: "test-password", voiceBridge: "1234567" } + stub_params = { meetingID: "test-meeting-1", moderatorPW: "test-password", voiceBridge: "1234567", 'meta_tenant-id': tenant.id } + + stub_create = stub_request(:get, encode_bbb_uri("create", server.url, server.secret, stub_params)) + .to_return(body: "SUCCESStest-meeting-1 + apmp") + + get bigbluebutton_api_create_url, params: create_params + + response_xml = Nokogiri.XML(response.body) + expect(stub_create).to have_been_requested + expect(response_xml.at_xpath("/response/returncode").text).to eq("SUCCESS") + end + end end end diff --git a/spec/services/lrs_payload_service_spec.rb b/spec/services/lrs_payload_service_spec.rb new file mode 100644 index 00000000..841f47a5 --- /dev/null +++ b/spec/services/lrs_payload_service_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LrsPayloadService, type: :service do + let!(:tenant) do + create(:tenant, + name: 'bn', + lrs_endpoint: 'https://lrs_endpoint.com', + kc_token_url: 'https://token_url.com/auth/token', + kc_client_id: 'client_id', + kc_client_secret: 'client_secret', + kc_username: 'kc_username', + kc_password: 'kc_password') + end + + describe '#call' do + it 'makes a call to kc_token_url with the correct payload' do + payload = { + client_id: tenant.kc_client_id, + client_secret: tenant.kc_client_secret, + username: tenant.kc_username, + password: tenant.kc_password, + grant_type: 'password' + } + + stub_create = stub_request(:post, tenant.kc_token_url) + .with(body: payload).to_return(body: "kc_access_token") + + described_class.new(tenant: tenant, secret: 'server-secret').call + + expect(stub_create).to have_been_requested + end + + it 'logs a warning and returns nil if kc_token_url returns an error' do + stub_request(:post, tenant.kc_token_url) + .to_return(status: 500, body: 'Internal Server Error', headers: {}) + + expect(Rails.logger).to receive(:warn) + + expect(described_class.new(tenant: tenant, secret: 'server-secret').call).to be_nil + end + end +end