From b31dbdfec315874761321d2c837f41febe662be2 Mon Sep 17 00:00:00 2001 From: GlennGeelen Date: Fri, 11 Jan 2019 14:24:18 +0100 Subject: [PATCH] Add the OAuth verification and header also extract double functions to a helper struct --- lib/lti.ex | 99 +++------------------------------------- lib/lti/helpers.ex | 102 ++++++++++++++++++++++++++++++++++++++++++ lib/lti/oauth.ex | 49 ++++++++++++++++++++ mix.exs | 3 +- mix.lock | 12 +++-- test/helpers_test.exs | 77 +++++++++++++++++++++++++++++++ test/lti_test.exs | 10 +---- test/oauth_test.exs | 80 +++++++++++++++++++++++++++++++++ 8 files changed, 325 insertions(+), 107 deletions(-) create mode 100644 lib/lti/helpers.ex create mode 100644 lib/lti/oauth.ex create mode 100644 test/helpers_test.exs create mode 100644 test/oauth_test.exs diff --git a/lib/lti.ex b/lib/lti.ex index a71e3fa..4a30de5 100644 --- a/lib/lti.ex +++ b/lib/lti.ex @@ -2,7 +2,7 @@ defmodule LTI do @moduledoc """ A library to launch a LTI request """ - alias LTI.{Credentials, OAuthData, LaunchParams} + alias LTI.{Credentials, Helpers, OAuthData, LaunchParams} def credentials(url, key, secret) do %Credentials{url: url, key: key, secret: secret} @@ -13,109 +13,22 @@ defmodule LTI do oauth_callback: "about:blank", oauth_consumer_key: key, oauth_version: "1.0", - oauth_nonce: nonce(), - oauth_timestamp: timestamp(), + oauth_nonce: Helpers.nonce(), + oauth_timestamp: Helpers.timestamp(), oauth_signature_method: "HMAC-SHA1" } end - def launch_data(%OAuthData{} = oauth, %LaunchParams{} = launch_params) do - struct_to_list(launch_params) ++ struct_to_list(oauth) - end - def signature( - %Credentials{secret: secret} = creds, + %Credentials{secret: secret, url: url}, %OAuthData{} = oauth_params, %LaunchParams{} = launch_params ) do :sha |> :crypto.hmac( - encode_secret(secret), - base_string(creds, oauth_params, launch_params) + Helpers.encode_secret(secret), + Helpers.base_string(url, oauth_params, launch_params) ) |> Base.encode64() end - - defp encode_secret(secret) do - "#{percent_encode(secret)}&" - end - - defp base_string(%Credentials{url: url}, oauth_params, launch_params) do - {normalized_url, query} = parse_url(url) - query_params = to_query_params(query) - - query = - oauth_params - |> launch_query(launch_params, query_params) - |> Enum.join("&") - |> percent_encode() - - "POST&#{percent_encode(normalized_url)}&#{query}" - end - - def launch_query(%OAuthData{} = oauth, %LaunchParams{} = launch_params, query_string_params) do - parameters = launch_data(oauth, launch_params) ++ query_string_params - - parameters - |> Enum.reduce(%{}, fn {key, value}, acc -> - Map.put(acc, key, value) - end) - |> Enum.reduce([], fn {key, value}, acc -> - acc ++ ["#{key}=#{percent_encode(value)}"] - end) - end - - defp parse_url(url) do - %URI{scheme: scheme, authority: authority, path: path, query: query} = URI.parse(url) - normalized_url = "#{scheme}://#{authority}#{path}" - {normalized_url, query} - end - - defp to_query_params(nil), do: [] - - defp to_query_params(query) do - query - |> String.split("&") - |> Enum.map(fn pair -> - [key, value] = String.split(pair, "=") - {String.to_atom(key), value} - end) - |> Keyword.new() - end - - defp timestamp do - {megasec, sec, _mcs} = :os.timestamp() - "#{megasec * 1_000_000 + sec}" - end - - defp percent_encode({key, value}) do - {percent_encode(key), percent_encode(value)} - end - - defp percent_encode(other) do - other - |> to_string() - |> URI.encode(&URI.char_unreserved?/1) - end - - defp struct_to_list(struct), - do: - struct - |> Map.from_struct() - |> Map.to_list() - |> strip_nil() - - defp strip_nil(list) do - Enum.reduce(list, [], fn {_, value} = item, acc -> - if is_nil(value), - do: acc, - else: acc ++ [item] - end) - end - - defp nonce do - 24 - |> :crypto.strong_rand_bytes() - |> Base.encode64() - end end diff --git a/lib/lti/helpers.ex b/lib/lti/helpers.ex new file mode 100644 index 0000000..525e62a --- /dev/null +++ b/lib/lti/helpers.ex @@ -0,0 +1,102 @@ +defmodule LTI.Helpers do + @moduledoc """ + A module for helper functions that are used for the OAuth params + """ + + alias LTI.{Credentials, LaunchParams, OAuthData} + + def base_string(url, %OAuthData{} = oauth_params, %LaunchParams{} = launch_params) do + params = launch_data(oauth_params, launch_params) + base_string(url, params) + end + + def base_string(url, oauth_params, launch_params) do + params = oauth_params ++ launch_params + base_string(url, params) + end + + def percent_encode({key, value}) do + {percent_encode(key), percent_encode(value)} + end + + def percent_encode(other) do + other + |> to_string() + |> URI.encode(&URI.char_unreserved?/1) + end + + def nonce do + 24 + |> :crypto.strong_rand_bytes() + |> Base.encode64() + end + + def timestamp do + {megasec, sec, _mcs} = :os.timestamp() + "#{megasec * 1_000_000 + sec}" + end + + def encode_secret(secret) do + "#{percent_encode(secret)}&" + end + + defp base_string(url, params) do + {normalized_url, query} = parse_url(url) + query_params = to_query_params(query) + + query = + (params ++ query_params) + |> launch_query() + |> Enum.join("&") + |> percent_encode() + + "POST&#{percent_encode(normalized_url)}&#{query}" + end + + defp launch_query(parameters) do + parameters + |> Enum.reduce(%{}, fn {key, value}, acc -> + Map.put(acc, key, value) + end) + |> Enum.reduce([], fn {key, value}, acc -> + acc ++ ["#{key}=#{percent_encode(value)}"] + end) + end + + defp to_query_params(nil), do: [] + + defp to_query_params(query) do + query + |> String.split("&") + |> Enum.map(fn pair -> + [key, value] = String.split(pair, "=") + {String.to_atom(key), value} + end) + |> Keyword.new() + end + + defp launch_data(%OAuthData{} = oauth, %LaunchParams{} = launch_params) do + struct_to_list(launch_params) ++ struct_to_list(oauth) + end + + defp parse_url(url) do + %URI{scheme: scheme, authority: authority, path: path, query: query} = URI.parse(url) + normalized_url = "#{scheme}://#{authority}#{path}" + {normalized_url, query} + end + + defp struct_to_list(struct), + do: + struct + |> Map.from_struct() + |> Map.to_list() + |> strip_nil() + + defp strip_nil(list) do + Enum.reduce(list, [], fn {_, value} = item, acc -> + if is_nil(value), + do: acc, + else: acc ++ [item] + end) + end +end diff --git a/lib/lti/oauth.ex b/lib/lti/oauth.ex new file mode 100644 index 0000000..5ce2998 --- /dev/null +++ b/lib/lti/oauth.ex @@ -0,0 +1,49 @@ +defmodule LTI.OAuth do + @moduledoc """ + Module containing functions to determine and compare oauth 1.0 signatures. + """ + alias LTI.{Credentials, Helpers} + + def oauth_verification(secret, url, oauth_params, regular_params, signature) do + with basestring <- Helpers.base_string(url, oauth_params, regular_params), + {:ok, calculated_signature} <- signature(secret, basestring) do + if calculated_signature == signature do + {:ok, :oauth_successful} + else + {:error, :signatures_not_matching} + end + end + end + + def generate_oauth_header(lis_outcome_service_url, %Credentials{key: key, secret: secret}) do + nonce = Helpers.nonce() + timestamp = Helpers.timestamp() + + oauth_params = [ + oauth_consumer_key: key, + oauth_nonce: nonce, + oauth_signature_method: "HMAC-SHA1", + oauth_version: "1.0", + oauth_timestamp: timestamp + ] + + basestring = Helpers.base_string(lis_outcome_service_url, oauth_params, []) + {:ok, calculated_signature} = signature(secret, basestring) + + ~s(OAuth oauth_consumer_key="#{key}", oauth_nonce="#{Helpers.percent_encode(nonce)}", oauth_signature_method="HMAC-SHA1", oauth_version="1.0", oauth_timestamp="#{ + timestamp + }", oauth_signature="#{Helpers.percent_encode(calculated_signature)}") + end + + defp signature(secret, basestring) do + signature = + :sha + |> :crypto.hmac( + Helpers.encode_secret(secret), + basestring + ) + |> Base.encode64() + + {:ok, signature} + end +end diff --git a/mix.exs b/mix.exs index 3226d31..fd31a85 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,8 @@ defmodule LTI.Mixfile do defp deps do [ {:credo, "~> 0.8", only: [:dev, :test], runtime: false}, - {:ex_doc, ">= 0.0.0", only: [:dev, :test]} + {:ex_doc, ">= 0.0.0", only: [:dev, :test]}, + {:mock, "~> 0.3.0", only: :test} ] end end diff --git a/mix.lock b/mix.lock index e49fa01..c633260 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,8 @@ -%{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [], [], "hexpm"}, - "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}} +%{ + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, + "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, + "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, + "mock": {:hex, :mock, "0.3.2", "e98e998fd76c191c7e1a9557c8617912c53df3d4a6132f561eb762b699ef59fa", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, +} diff --git a/test/helpers_test.exs b/test/helpers_test.exs new file mode 100644 index 0000000..f161ce8 --- /dev/null +++ b/test/helpers_test.exs @@ -0,0 +1,77 @@ +defmodule HelpersTest do + use ExUnit.Case + + alias LTI.{Helpers, LaunchParams, OAuthData} + + describe "#base_string/3" do + test "returns the base string with given params as arrays" do + oauth_params = [ + oauth_consumer_key: "key", + oauth_nonce: "nonce" + ] + + basestring = Helpers.base_string("http://example.com", oauth_params, []) + + assert basestring == + "POST&http%3A%2F%2Fexample.com&oauth_consumer_key%3Dkey%26oauth_nonce%3Dnonce" + end + + test "returns a base string with OAuthData and LaunchParam structs" do + oauth_credentials = %OAuthData{ + oauth_callback: "about:blank", + oauth_consumer_key: "key", + oauth_version: "1.0", + oauth_nonce: "nonce", + oauth_timestamp: "timestamp", + oauth_signature_method: "HMAC-SHA1" + } + + valid_launch_params = %LaunchParams{ + context_id: "456434513", + launch_presentation_locale: "en", + launch_presentation_return_url: "url", + lis_person_contact_email_primary: "user@wtf.nl", + lis_person_name_full: "whoot at waaht", + lti_message_type: "basic-lti-launch-request", + lti_version: "LTI-1p0", + resource_link_description: "A weekly blog.", + resource_link_id: "120988f929-274612", + resource_link_title: "onno schuit", + roles: "Student", + submit: "Launch", + tool_consumer_instance_guid: "lmsng.school.edu", + user_id: 1234 + } + + basestring = + Helpers.base_string("http://example.com", oauth_credentials, valid_launch_params) + + assert basestring == + "POST&http%3A%2F%2Fexample.com&context_id%3D456434513%26launch_presentation_locale%3Den%26launch_presentation_return_url%3Durl%26lis_person_contact_email_primary%3Duser%2540wtf.nl%26lis_person_name_full%3Dwhoot%2520at%2520waaht%26lti_message_type%3Dbasic-lti-launch-request%26lti_version%3DLTI-1p0%26oauth_callback%3Dabout%253Ablank%26oauth_consumer_key%3Dkey%26oauth_nonce%3Dnonce%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3Dtimestamp%26oauth_version%3D1.0%26resource_link_description%3DA%2520weekly%2520blog.%26resource_link_id%3D120988f929-274612%26resource_link_title%3Donno%2520schuit%26roles%3DStudent%26submit%3DLaunch%26tool_consumer_instance_guid%3Dlmsng.school.edu%26user_id%3D1234" + end + + test "returns an error when not supported" do + assert_raise ArgumentError, fn -> + Helpers.base_string("some_url", %{key: "value"}, []) + end + end + end + + describe "#percent_encode/1" do + test "returns a tuple encoded" do + encoded_value = Helpers.percent_encode({:user_email, "user@wtf.nl"}) + + assert encoded_value == {"user_email", "user%40wtf.nl"} + end + + test "returns an encoded value" do + assert Helpers.percent_encode("user@wtf.nl") == "user%40wtf.nl" + end + end + + describe "#encode_secret/1" do + test "returns secret encoded" do + assert Helpers.encode_secret("secret") == "secret&" + end + end +end diff --git a/test/lti_test.exs b/test/lti_test.exs index 918d484..784e3af 100644 --- a/test/lti_test.exs +++ b/test/lti_test.exs @@ -20,7 +20,7 @@ defmodule LTITest do oauth_signature_method: "HMAC-SHA1" } - @valid_launch_params %LTI.LaunchParams{ + @valid_launch_params %LaunchParams{ context_id: "456434513", launch_presentation_locale: "en", launch_presentation_return_url: "url", @@ -37,14 +37,6 @@ defmodule LTITest do user_id: 1234 } - test "launch_data/2 contains all needed params" do - oauth_params = LTI.oauth_params(@credentials) - launch_data = LTI.launch_query(oauth_params, @valid_launch_params, []) - - assert "roles=Student" in launch_data - assert "oauth_signature_method=HMAC-SHA1" in launch_data - end - test "signature/3 encodes all the variables " do assert LTI.signature(@credentials, @oauth_credentials, @valid_launch_params) == "FmlHij11a+wcY4XPmjyRrPGNELg=" diff --git a/test/oauth_test.exs b/test/oauth_test.exs new file mode 100644 index 0000000..fa3d561 --- /dev/null +++ b/test/oauth_test.exs @@ -0,0 +1,80 @@ +defmodule OAuthTest do + use ExUnit.Case + + import Mock + + alias LTI + alias LTI.{Credentials, Helpers, LaunchParams, OAuth, OAuthData} + + @credentials %Credentials{url: "https://exmaple.com", secret: "secret", key: "key"} + + @oauth_credentials %OAuthData{ + oauth_callback: "about:blank", + oauth_consumer_key: "key", + oauth_version: "1.0", + oauth_nonce: "some_nonce", + oauth_timestamp: 1_029_382, + oauth_signature_method: "HMAC-SHA1" + } + + @valid_launch_params %LaunchParams{ + context_id: "456434513", + launch_presentation_locale: "en", + launch_presentation_return_url: "https://exmaple.com", + lis_person_contact_email_primary: "user@wtf.nl", + lis_person_name_full: "whoot at waaht", + lti_message_type: "basic-lti-launch-request", + lti_version: "LTI-1p0", + resource_link_description: "A weekly blog.", + resource_link_id: "120988f929-274612", + resource_link_title: "onno schuit", + roles: "Student", + submit: "Launch", + tool_consumer_instance_guid: "lmsng.school.edu", + user_id: 1234 + } + + describe "#oauth_verification/5" do + setup do + signature = LTI.signature(@credentials, @oauth_credentials, @valid_launch_params) + + {:ok, signature: signature} + end + + test "returns success when signatures match", %{signature: signature} do + assert OAuth.oauth_verification( + "secret", + "https://exmaple.com", + @oauth_credentials, + @valid_launch_params, + signature + ) == {:ok, :oauth_successful} + end + + test "returns error when signatures do not match", %{signature: signature} do + assert OAuth.oauth_verification( + "secret", + "random_url", + @oauth_credentials, + @valid_launch_params, + signature + ) == {:error, :signatures_not_matching} + end + end + + describe "#generate_oauth_header/2" do + setup_with_mocks [ + {Helpers, [:passthrough], nonce: fn -> "some_nonce" end}, + {Helpers, [:passthrough], timestamp: fn -> 1_029_382 end} + ] do + :ok + end + + test "returns the OAuth header" do + oauth_header = OAuth.generate_oauth_header("https://exmaple.com", @credentials) + + assert oauth_header == + "OAuth oauth_consumer_key=\"key\", oauth_nonce=\"some_nonce\", oauth_signature_method=\"HMAC-SHA1\", oauth_version=\"1.0\", oauth_timestamp=\"1029382\", oauth_signature=\"skUfHD74wpsM9UyCWHrg%2BR1HHWo%3D\"" + end + end +end