From 4f5a246014e68f8fa87473466b5d22e1eff47a0c Mon Sep 17 00:00:00 2001 From: Alejandro Mezcua Date: Mon, 26 Sep 2016 12:38:26 +0200 Subject: [PATCH 1/4] Reformat code to eliminate unused variables warnings. --- lib/googlecerts.ex | 2 +- lib/jwt.ex | 5 +++-- mix.exs | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/googlecerts.ex b/lib/googlecerts.ex index bd7b054..7636ed7 100644 --- a/lib/googlecerts.ex +++ b/lib/googlecerts.ex @@ -23,7 +23,7 @@ defmodule Jwt.GoogleCerts.PublicKey do end defp get_response_body(%{body: body, headers: _headers, status_code: 200}), do: {:ok, body} - defp get_response_body(%{body: body, headers: _headers, status_code: status_code}), do: {:error, body} + defp get_response_body(%{body: body, headers: _headers, status_code: _status_code}), do: {:error, body} defp extract_certificated_url({:ok, body}) do {:ok, parsed} = Poison.Parser.parse body diff --git a/lib/jwt.ex b/lib/jwt.ex index a123d0a..8dc60ad 100644 --- a/lib/jwt.ex +++ b/lib/jwt.ex @@ -6,7 +6,8 @@ defmodule Jwt do @alg "alg" @doc """ - Verifies a Google generated JWT token against the current public Google certificates and returns the claims if the token is verified successfully. + Verifies a Google generated JWT token against the current public Google certificates and returns the claims + if the token's signature is verified successfully. ## Example iex > {:ok, {claims}} = Jwt.verify token @@ -17,7 +18,7 @@ defmodule Jwt do _verify(Enum.map(token_parts, fn(part) -> Base.url_decode64(part, padding: false) end), token_parts) end - defp _verify([{:ok, header}, {:ok, claims}, {:ok, signature}], [header_b64, claims_b64, _signature_b64]) do + defp _verify([{:ok, header}, {:ok, _claims}, {:ok, signature}], [header_b64, claims_b64, _signature_b64]) do Poison.Parser.parse!(header)[@key_id] |> @google_certs_api.getfor |> verify_signature(header_b64, claims_b64, signature) diff --git a/mix.exs b/mix.exs index d573f09..e01a92c 100644 --- a/mix.exs +++ b/mix.exs @@ -16,7 +16,7 @@ defmodule Jwt.Mixfile do end defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(:dev), do: ["lib", "test/support"] + defp elixirc_paths(:dev), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp deps do From db598f5573542804d17830e523b93c5c84f1cc0e Mon Sep 17 00:00:00 2001 From: Alejandro Mezcua Date: Tue, 27 Sep 2016 13:52:46 +0200 Subject: [PATCH 2/4] Added verification of token timestamp. Token expiration time is now checked by the plug and rejected if its expiration date is old. A 5 minute margin is substracted from the expiration timestamp to allow for time differences between this machine and the one that generated the token. --- config/test.exs | 3 +- lib/plug.ex | 35 ++++++++++-- lib/timeutils.ex | 3 + test/certs_test.exs | 2 - test/jwt_plug_test.exs | 105 +++++++++++++++++++++++++++------- test/support/timeutilsmock.ex | 5 ++ 6 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 lib/timeutils.ex create mode 100644 test/support/timeutilsmock.ex diff --git a/config/test.exs b/config/test.exs index 6ad42ba..7c48e70 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,4 +2,5 @@ use Mix.Config config :logger, level: :debug -config :jwt, :googlecerts, Jwt.GoogleCerts.PublicKey.Mock \ No newline at end of file +config :jwt, :googlecerts, Jwt.GoogleCerts.PublicKey.Mock +config :jwt, :timeutils, Jwt.TimeUtils.Mock \ No newline at end of file diff --git a/lib/plug.ex b/lib/plug.ex index 57518d9..eca8d03 100644 --- a/lib/plug.ex +++ b/lib/plug.ex @@ -1,16 +1,24 @@ defmodule Jwt.Plug do import Plug.Conn + require Logger + + @timeutils Application.get_env(:jwt, :timeutils, Jwt.TimeUtils) @authorization_header "authorization" @bearer "Bearer " @invalid_header_error {:error, "Invalid authorization header value."} + @expired_token_error {:error, "Expired token."} + @five_minutes 5 * 60 - def init([]), do: false + def init(opts) do + opts = Dict.put_new(opts, :ignore_token_expiration, false) + Dict.put_new(opts, :time_window, @five_minutes) + end - def call(conn, _opts) do + def call(conn, opts) do List.first(get_req_header(conn, @authorization_header)) |> extract_token - |> verify + |> verify_token(opts) |> continue_if_verified(conn) end @@ -22,8 +30,25 @@ defmodule Jwt.Plug do end defp extract_token(_), do: @invalid_header_error - defp verify({:ok, token}), do: Jwt.verify(token) - defp verify({:error, _}), do: @invalid_header_error + defp verify_token({:ok, token}, opts) do + verify_signature(token) + |> verify_expiration(opts) + end + defp verify_token({:error, _}, _opts), do: @invalid_header_error + + defp verify_signature(token), do: Jwt.verify(token) + + defp verify_expiration({:ok, claims}, opts) do + expiration_date = claims["exp"] - opts.time_window + now = @timeutils.get_system_time() + + cond do + opts.ignore_token_expiration -> {:ok, claims} + now > expiration_date -> @expired_token_error + now < expiration_date -> {:ok, claims} + end + end + defp verify_expiration({:error, _}, _opts), do: @invalid_header_error defp continue_if_verified({:ok, claims}, conn) do assign(conn, :jwtclaims, claims) diff --git a/lib/timeutils.ex b/lib/timeutils.ex new file mode 100644 index 0000000..3353a83 --- /dev/null +++ b/lib/timeutils.ex @@ -0,0 +1,3 @@ +defmodule Jwt.TimeUtils do + def get_system_time(), do: :os.system_time(:seconds) +end \ No newline at end of file diff --git a/test/certs_test.exs b/test/certs_test.exs index b982cc1..d1a9f23 100644 --- a/test/certs_test.exs +++ b/test/certs_test.exs @@ -1,8 +1,6 @@ defmodule CertsTest do use ExUnit.Case, async: true - require Logger - @mod "zkSRsA8npcga4dKSt91-OtSXA481Y94jt5tn64h2MUtUnQ_1JP-4xcDBYVG52m1Cdc7Fq2_cpUOvm27jAxIc4oYxLk1YtyJX9ce5p2rkbKyC71nSq5om3rBE4n3hYUa0nPCcXNC0uC_G0UTVY_OsiYS6hSNVSnHqySn50yid8EBWY8sHHCsqEtlk4uwXXalgnpZ5BXI22yQWQASnZdeIiRKhxSWdkDrbLUq1FmyfNn9vabhIADZsdjCL3iCfJVW8YTdntObZRVsuh_ezm9K7-l3U400EvZA7RN_Dt5QGC6gSjo4syP5TkGXD6iC6rUx67FLzgww_Lo0O4kYEFzDLzw" @exp "AQAB" @header "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" diff --git a/test/jwt_plug_test.exs b/test/jwt_plug_test.exs index 9ce56ae..47aa67d 100644 --- a/test/jwt_plug_test.exs +++ b/test/jwt_plug_test.exs @@ -1,14 +1,21 @@ defmodule JwtPlugTest do use ExUnit.Case, async: true use Plug.Test - require Logger - @opts Jwt.Plug.init([]) + @opts %{} + + @test_header "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" + @test_claims "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiIzNDczODQ1NjIxMTMtcmRtNnNsZG0xbWIzOGs0dW1yY28zcDhsN3I1aGcwazUuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTAzNjE0MDAyNDQ4NzEyMjU0MTQiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXpwIjoiMzQ3Mzg0NTYyMTEzLXIyOHBqZDB1Yzlwb2Y1Y20xcDBubmwyNXM5N2o4dXFwLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJieXRlYWJ5dGUubmV0IiwiZW1haWwiOiJhbGVqYW5kcm8ubWV6Y3VhQGJ5dGVhYnl0ZS5uZXQiLCJpYXQiOjE0NzMyMjU4NjQsImV4cCI6MTQ3MzIyOTQ2NCwibmFtZSI6IkFsZWphbmRybyBNZXpjdWEiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tLy1mSUpUN0cydVozRS9BQUFBQUFBQUFBSS9BQUFBQUFBQUFRWS9KdkNWbUZIWG5yOC9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiQWxlamFuZHJvIiwiZmFtaWx5X25hbWUiOiJNZXpjdWEiLCJsb2NhbGUiOiJlbiJ9" + @test_signature "Kc90u_gtZyhq6glw6UoYQSInZx9r16uqrRO7g50x17JWH7VkyAZrh3sfjdBYpGtDNJjDRBKSxuinpDjpyfiCp3-XAqqOUWqziyYvkV4-CdQvNhcnUQFXjjx_CzNiiEi5PRPCHhX4ajidet1NH4Me02S17gwOZiaZfed1BMWQuQ_7Hf2RsX5FID1xqOpcaaouMFcrqQFmdBIbcstHamWxs9D83c4JpOsioNOMb6-LBinzOg7qdxr1D4NvHD6VSXBTbyXiOBjK2elLU1iCz_Hz_BH-R1IYCdTRr5PczRWdSCgoTdZ7ds1nTTglfuXlGNbaEhhzsFxX8OCR4uNK6vbWXQ" + @test_token_exp_value 1473229464 # Expiration timestamp for the token avobe + @ten_minutes 10 * 60 + @four_minutes 4 * 60 test "Missing authorization header returns 401" do + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) conn = conn(:get, "/protected") - conn = Jwt.Plug.call(conn, @opts) + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) assert conn.state == :sent assert conn.status == 401 @@ -16,42 +23,96 @@ defmodule JwtPlugTest do end test "Empty authorization header returns 401" do - conn = conn(:get, "/protected") - conn = put_req_header conn, "authorization", "" + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", "" - conn = Jwt.Plug.call(conn, @opts) + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) - assert conn.state == :sent - assert conn.status == 401 - assert conn.resp_body == "" + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" end test "Invalid token in authorization header returns 401" do - conn = conn(:get, "/protected") - conn = put_req_header conn, "authorization", "token" + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", "token" - conn = Jwt.Plug.call(conn, @opts) + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) - assert conn.state == :sent - assert conn.status == 401 - assert conn.resp_body == "" + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" end test "Valid token is allowed" do - header = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" - claims = "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiIzNDczODQ1NjIxMTMtcmRtNnNsZG0xbWIzOGs0dW1yY28zcDhsN3I1aGcwazUuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTAzNjE0MDAyNDQ4NzEyMjU0MTQiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXpwIjoiMzQ3Mzg0NTYyMTEzLXIyOHBqZDB1Yzlwb2Y1Y20xcDBubmwyNXM5N2o4dXFwLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJieXRlYWJ5dGUubmV0IiwiZW1haWwiOiJhbGVqYW5kcm8ubWV6Y3VhQGJ5dGVhYnl0ZS5uZXQiLCJpYXQiOjE0NzMyMjU4NjQsImV4cCI6MTQ3MzIyOTQ2NCwibmFtZSI6IkFsZWphbmRybyBNZXpjdWEiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tLy1mSUpUN0cydVozRS9BQUFBQUFBQUFBSS9BQUFBQUFBQUFRWS9KdkNWbUZIWG5yOC9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiQWxlamFuZHJvIiwiZmFtaWx5X25hbWUiOiJNZXpjdWEiLCJsb2NhbGUiOiJlbiJ9" - signature = "Kc90u_gtZyhq6glw6UoYQSInZx9r16uqrRO7g50x17JWH7VkyAZrh3sfjdBYpGtDNJjDRBKSxuinpDjpyfiCp3-XAqqOUWqziyYvkV4-CdQvNhcnUQFXjjx_CzNiiEi5PRPCHhX4ajidet1NH4Me02S17gwOZiaZfed1BMWQuQ_7Hf2RsX5FID1xqOpcaaouMFcrqQFmdBIbcstHamWxs9D83c4JpOsioNOMb6-LBinzOg7qdxr1D4NvHD6VSXBTbyXiOBjK2elLU1iCz_Hz_BH-R1IYCdTRr5PczRWdSCgoTdZ7ds1nTTglfuXlGNbaEhhzsFxX8OCR4uNK6vbWXQ" - valid_token = header <> "." <> claims <> "." <> signature - + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + valid_token = @test_header <> "." <> @test_claims <> "." <> @test_signature auth_header= "Bearer " <> valid_token + conn = conn(:get, "/protected") conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(%{:ignore_token_expiration => true})) + + claims = conn.assigns[:jwtclaims] + assert claims != nil + assert claims["name"] == "Alejandro Mezcua" + end + + test "Expired token is rejected by default" do + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + expired_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_token + + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) + + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" + end + + test "Token expiration allowed below but outside time window" do + Application.put_env(:jwt, :current_time_for_test, @test_token_exp_value - @ten_minutes) + expired_in_window_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_in_window_token - conn = Jwt.Plug.call(conn, @opts) + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) claims = conn.assigns[:jwtclaims] - Logger.debug fn -> "Claims: " <> inspect(claims) end assert claims != nil assert claims["name"] == "Alejandro Mezcua" end + + test "Token expiration not allowed below but within time window" do + Application.put_env(:jwt, :current_time_for_test, @test_token_exp_value - @four_minutes) + expired_in_window_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_in_window_token + + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) + + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" + end + + test "Token expiration not allowed avobe expiration time" do + Application.put_env(:jwt, :current_time_for_test, @test_token_exp_value + 1) + expired_in_window_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_in_window_token + + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) + + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" + end end \ No newline at end of file diff --git a/test/support/timeutilsmock.ex b/test/support/timeutilsmock.ex new file mode 100644 index 0000000..602affa --- /dev/null +++ b/test/support/timeutilsmock.ex @@ -0,0 +1,5 @@ +defmodule Jwt.TimeUtils.Mock do + + def get_system_time(), do: Application.get_env(:jwt, :current_time_for_test) + +end \ No newline at end of file From ee223a0af0667dfb26fceee3b13cfd627424d440 Mon Sep 17 00:00:00 2001 From: Alejandro Mezcua Date: Tue, 27 Sep 2016 13:55:29 +0200 Subject: [PATCH 3/4] Added token expiration verification. --- README.md | 2 ++ mix.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 63143a8..d537b21 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ name = claims["name"] If the token is invalid, the plug with directly return a 401 response to the client. +The tokens expiration timestamp are also checked to verify that they have not expired. Expired tokens (within a 5 minute time difference) are rejected. + ## Limitations * At this point the library only can verify RSA SHA256 signed tokens. It uses the [public discovery document](https://developers.google.com/identity/protocols/OpenIDConnect#discovery) provided by Google to retrieve the public key used to verify the RSA signatures but if the signing method is changed by Google the library will fail to verify the tokens. diff --git a/mix.exs b/mix.exs index e01a92c..25a730e 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Jwt.Mixfile do def project do [app: :jwt, - version: "0.2.1", + version: "0.2.2", elixir: "~> 1.3", elixirc_paths: elixirc_paths(Mix.env), build_embedded: Mix.env == :prod, From adaca0b9ddd7b49bf5eae64a4424fd89fac661ad Mon Sep 17 00:00:00 2001 From: Alejandro Mezcua Date: Tue, 27 Sep 2016 14:24:59 +0200 Subject: [PATCH 4/4] Fix. Options passed to the init function can't be a map. Used a list instead. --- lib/plug.ex | 13 +++++++++---- test/jwt_plug_test.exs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/plug.ex b/lib/plug.ex index eca8d03..a95f9dc 100644 --- a/lib/plug.ex +++ b/lib/plug.ex @@ -9,10 +9,13 @@ defmodule Jwt.Plug do @invalid_header_error {:error, "Invalid authorization header value."} @expired_token_error {:error, "Expired token."} @five_minutes 5 * 60 + @default_options %{:ignore_token_expiration => false, :time_window => @five_minutes} def init(opts) do - opts = Dict.put_new(opts, :ignore_token_expiration, false) - Dict.put_new(opts, :time_window, @five_minutes) + case Enum.count(opts) do + 2 -> opts + _ -> [@default_options.ignore_token_expiration, @default_options.time_window] + end end def call(conn, opts) do @@ -39,11 +42,13 @@ defmodule Jwt.Plug do defp verify_signature(token), do: Jwt.verify(token) defp verify_expiration({:ok, claims}, opts) do - expiration_date = claims["exp"] - opts.time_window + [ignore_token_expiration, time_window] = opts + + expiration_date = claims["exp"] - time_window now = @timeutils.get_system_time() cond do - opts.ignore_token_expiration -> {:ok, claims} + ignore_token_expiration -> {:ok, claims} now > expiration_date -> @expired_token_error now < expiration_date -> {:ok, claims} end diff --git a/test/jwt_plug_test.exs b/test/jwt_plug_test.exs index 47aa67d..abf07af 100644 --- a/test/jwt_plug_test.exs +++ b/test/jwt_plug_test.exs @@ -2,7 +2,7 @@ defmodule JwtPlugTest do use ExUnit.Case, async: true use Plug.Test - @opts %{} + @opts [] @test_header "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" @test_claims "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiIzNDczODQ1NjIxMTMtcmRtNnNsZG0xbWIzOGs0dW1yY28zcDhsN3I1aGcwazUuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTAzNjE0MDAyNDQ4NzEyMjU0MTQiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXpwIjoiMzQ3Mzg0NTYyMTEzLXIyOHBqZDB1Yzlwb2Y1Y20xcDBubmwyNXM5N2o4dXFwLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJieXRlYWJ5dGUubmV0IiwiZW1haWwiOiJhbGVqYW5kcm8ubWV6Y3VhQGJ5dGVhYnl0ZS5uZXQiLCJpYXQiOjE0NzMyMjU4NjQsImV4cCI6MTQ3MzIyOTQ2NCwibmFtZSI6IkFsZWphbmRybyBNZXpjdWEiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tLy1mSUpUN0cydVozRS9BQUFBQUFBQUFBSS9BQUFBQUFBQUFRWS9KdkNWbUZIWG5yOC9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiQWxlamFuZHJvIiwiZmFtaWx5X25hbWUiOiJNZXpjdWEiLCJsb2NhbGUiOiJlbiJ9" @@ -53,7 +53,7 @@ defmodule JwtPlugTest do conn = conn(:get, "/protected") conn = put_req_header conn, "authorization", auth_header - conn = Jwt.Plug.call(conn, Jwt.Plug.init(%{:ignore_token_expiration => true})) + conn = Jwt.Plug.call(conn, Jwt.Plug.init([true, 5 * 60])) claims = conn.assigns[:jwtclaims] assert claims != nil