diff --git a/lib/api_auth/date_header.ex b/lib/api_auth/date_header.ex index a34a1df..ef9a874 100644 --- a/lib/api_auth/date_header.ex +++ b/lib/api_auth/date_header.ex @@ -5,13 +5,13 @@ defmodule ApiAuth.DateHeader do @header_key :DATE @value_key :timestamp @allowed_skew 900 + @httpdate_format_string "%a, %d %b %Y %H:%M:%S GMT" + @utc_timezone "Etc/UTC" alias ApiAuth.HeaderValues alias ApiAuth.HeaderCompare - alias Calendar.DateTime - alias Calendar.DateTime.Format - alias Calendar.DateTime.Parse + alias Timex.Parse.DateTime.Parser def headers(hv) do hv |> HeaderValues.put_new(@keys, @header_key, @value_key, timestamp()) @@ -21,25 +21,69 @@ defmodule ApiAuth.DateHeader do hc |> HeaderCompare.compare(@keys, ×tamp_compare/2) end + @doc """ + Takes a DateTime and returns a string with the date-time in RFC 2616 format. + This format is used in the HTTP protocol. Note that the date-time will always be "shifted" to UTC. + """ + def httpdate(dt) do + Calendar.strftime(dt, @httpdate_format_string) + end + + @doc """ + Parses httpdates into Datetime structs + iex> parse_httpdate("Sat, 06 Sep 2014 09:09:08 GMT") + {:ok, + %DateTime{ + year: 2014, + month: 9, + day: 6, + hour: 9, + minute: 9, + second: 8, + time_zone: "Etc/UTC", + zone_abbr: "UTC", + std_offset: 0, + utc_offset: 0, + microsecond: {0, 0} + } + } + """ + def parse_httpdate(dt) do + case Parser.parse(dt, @httpdate_format_string, :strftime) do + {:ok, result} -> DateTime.from_naive(result, @utc_timezone) + {:error, error_msg} -> {:error, error_msg} + end + end + + defp now do + Timex.now(@utc_timezone) + end + + # NOTE: Returns current datetime in RFC 2616 format. + # Uses 'GMT' instead of 'UTC' for timezone. + # e.g. "Mon, 23 Oct 2023 14:45:18 GMT" defp timestamp do - DateTime.now_utc() - |> Format.httpdate() + now() + |> httpdate end defp timestamp_compare(t1, t2) do - t1 == t2 && timestamp_within_skew?(Parse.httpdate(t1)) + t1 == t2 && timestamp_within_skew?(parse_httpdate(t1)) end defp timestamp_within_skew?({:ok, time}) do - now = DateTime.now_utc() - - case DateTime.diff(now, time) do - {:ok, _, _, :same_time} -> true - {:ok, seconds, _, :after} -> seconds < @allowed_skew + case Timex.diff(now(), time, :second) do + seconds when seconds < 0 -> false + seconds when seconds == 0 -> true + seconds when seconds < @allowed_skew -> true _ -> false end end + defp timestamp_within_skew?({:error, _}) do + false + end + defp timestamp_within_skew?(_timestamp) do false end diff --git a/mix.exs b/mix.exs index 92a77f3..a56c6fa 100644 --- a/mix.exs +++ b/mix.exs @@ -22,7 +22,7 @@ defmodule ApiAuth.Mixfile do defp deps do [ - {:calendar, "~> 1.0"}, + {:timex, "~> 3.7.9"}, {:plug_crypto, "~> 1.0"}, {:credo, "~> 1.6.1", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.14", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 0458904..9deac1d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,13 @@ %{ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "credo": {:hex, :credo, "1.6.2", "2f82b29a47c0bb7b72f023bf3a34d151624f1cbe1e6c4e52303b05a11166a701", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ae9dc112bc368e7b145c547bec2ed257ef88955851c15057c7835251a17211c6"}, "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm", "3b1dcad3067985dd8618c38399a8ee9c4e652d52a17a4aae7a6d6fc4fcc24856"}, "ex_doc": {:hex, :ex_doc, "0.16.2", "3b3e210ebcd85a7c76b4e73f85c5640c011d2a0b2f06dcdf5acdb2ae904e5084", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm", "b6fb4aef8125c62e6b6a7d7507eff70f376a7050c7745af11f08333ea9beebd3"}, + "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, @@ -15,6 +17,7 @@ "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/test/api_auth/date_header_test.exs b/test/api_auth/date_header_test.exs index dbc70c0..d156063 100644 --- a/test/api_auth/date_header_test.exs +++ b/test/api_auth/date_header_test.exs @@ -5,8 +5,6 @@ defmodule ApiAuth.DateHeaderTest do alias ApiAuth.HeaderValues alias ApiAuth.HeaderCompare - alias Calendar.DateTime.Format - describe "headers" do test "it gets the value from the headers" do headers = [HTTP_DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] @@ -29,9 +27,8 @@ defmodule ApiAuth.DateHeaderTest do |> DateHeader.headers() |> HeaderValues.get(:timestamp) - diff = - Calendar.DateTime.now_utc() - |> DateTime.diff(Calendar.DateTime.Parse.httpdate!(value), :second) + {:ok, parsed} = DateHeader.parse_httpdate(value) + diff = Timex.diff(Timex.now(:utc), parsed, :second) assert diff == 0 end @@ -46,9 +43,8 @@ defmodule ApiAuth.DateHeaderTest do |> HeaderValues.unwrap() |> Keyword.fetch!(:DATE) - diff = - Calendar.DateTime.now_utc() - |> DateTime.diff(Calendar.DateTime.Parse.httpdate!(value), :second) + {:ok, parsed} = DateHeader.parse_httpdate(value) + diff = Timex.diff(Timex.now(:utc), parsed, :second) assert diff == 0 end @@ -68,7 +64,7 @@ defmodule ApiAuth.DateHeaderTest do describe "compare" do test "it is true when the timestamps match" do - timestamp = Format.httpdate(Calendar.DateTime.now_utc()) + timestamp = DateHeader.httpdate(Timex.now(:utc)) valid_headers = [DATE: timestamp] request_headers = [HTTP_DATE: timestamp] @@ -80,9 +76,10 @@ defmodule ApiAuth.DateHeaderTest do end test "it is false when the timestamps don't match" do - time = Calendar.DateTime.now_utc() - timestamp1 = Format.httpdate(time) - timestamp2 = Format.httpdate(Calendar.DateTime.subtract!(time, 1)) + now = Timex.now(:utc) + timestamp1 = DateHeader.httpdate(now) + past = Timex.shift(now, seconds: -1) + timestamp2 = DateHeader.httpdate(past) valid_headers = [DATE: timestamp1] request_headers = [HTTP_DATE: timestamp2] @@ -106,9 +103,8 @@ defmodule ApiAuth.DateHeaderTest do end test "it is true if the timestamps are within 15 minutes from now" do - time = Calendar.DateTime.subtract!(Calendar.DateTime.now_utc(), 800) - timestamp = Format.httpdate(time) - + time = Timex.shift(Timex.now(:utc), seconds: -800) + timestamp = DateHeader.httpdate(time) valid_headers = [DATE: timestamp] request_headers = [HTTP_DATE: timestamp] @@ -120,8 +116,8 @@ defmodule ApiAuth.DateHeaderTest do end test "it is false if the timestamps are older than 15 minutes" do - time = Calendar.DateTime.subtract!(Calendar.DateTime.now_utc(), 901) - timestamp = Format.httpdate(time) + time = Timex.shift(Timex.now(:utc), seconds: -901) + timestamp = DateHeader.httpdate(time) valid_headers = [DATE: timestamp] request_headers = [HTTP_DATE: timestamp] @@ -134,9 +130,8 @@ defmodule ApiAuth.DateHeaderTest do end test "it is false if the timestamps are in the future" do - time = Calendar.DateTime.add!(Calendar.DateTime.now_utc(), 1) - timestamp = Format.httpdate(time) - + time = Timex.shift(Timex.now(:utc), seconds: 1000) + timestamp = DateHeader.httpdate(time) valid_headers = [DATE: timestamp] request_headers = [HTTP_DATE: timestamp] @@ -147,4 +142,32 @@ defmodule ApiAuth.DateHeaderTest do |> refute() end end + + describe "httpdate" do + test "it formats the datetime object correctly" do + date = DateTime.new!(~D[2023-10-31], ~T[12:34:16], "Etc/UTC") + + assert DateHeader.httpdate(date) == "Tue, 31 Oct 2023 12:34:16 GMT" + end + end + + describe "parse_httpdate" do + test "it parses httpdate into datetime object" do + {:ok, result} = DateHeader.parse_httpdate("Tue, 31 Oct 2023 04:59:03 GMT") + + assert result == %DateTime{ + year: 2023, + month: 10, + day: 31, + hour: 4, + minute: 59, + second: 3, + time_zone: "Etc/UTC", + zone_abbr: "UTC", + std_offset: 0, + utc_offset: 0, + microsecond: {0, 0} + } + end + end end