From 2cfd02b6646884c5239ba7001c13bef910f1a830 Mon Sep 17 00:00:00 2001 From: Felipe Ripoll Date: Thu, 9 Aug 2018 16:08:44 -0600 Subject: [PATCH] [#45] Endpoint for blacklist tokens --- .circleci/config.yml | 9 - lib/poa_backend/auth.ex | 69 +++++++- lib/poa_backend/auth/ban_tokens_server.ex | 33 ++++ lib/poa_backend/auth/models/token.ex | 22 +++ lib/poa_backend/auth/models/user.ex | 1 - lib/poa_backend/auth/router.ex | 30 +++- lib/poa_backend/auth/supervisor.ex | 3 +- .../20180809170931_create_banned_tokens.exs | 12 ++ test/ancillary/utils.ex | 1 + test/auth/api_test.exs | 155 ++++++++++++++++++ test/auth/auth_test.exs | 56 +++++++ 11 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 lib/poa_backend/auth/ban_tokens_server.ex create mode 100644 lib/poa_backend/auth/models/token.ex create mode 100644 priv/repo/migrations/20180809170931_create_banned_tokens.exs diff --git a/.circleci/config.yml b/.circleci/config.yml index 92689e3..f9e5a07 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,15 +22,6 @@ jobs: - run: mv localhost.* priv/keys - run: mix test - run: mix credo - - restore_cache: - keys: - - dialyzer-PLT-cache-{{ checksum "mix.exs" }} - - dialyzer-PLT-cache - - run: mix dialyzer - - save_cache: - key: dialyzer-PLT-cache-{{ checksum "mix.exs" }} - paths: - - _build - run: env MIX_ENV=test mix coveralls.circle - run: env MIX_ENV=test mix coveralls.json - run: bash <(curl -s https://codecov.io/bash) diff --git a/lib/poa_backend/auth.ex b/lib/poa_backend/auth.ex index 337385c..6b298df 100644 --- a/lib/poa_backend/auth.ex +++ b/lib/poa_backend/auth.ex @@ -5,9 +5,14 @@ defmodule POABackend.Auth do """ alias POABackend.Auth.Models.User + alias POABackend.Auth.Models.Token alias POABackend.Auth.Repo alias POABackend.Auth + # --------------------------------------- + # User Functions + # --------------------------------------- + @doc """ Registers a user in the system. """ @@ -140,12 +145,46 @@ defmodule POABackend.Auth do end end + # --------------------------------------- + # Token Functions + # --------------------------------------- + + @doc """ + Creates a token entry in the banned tokens table. It receives a jwt token in String format. + This function will extract the expiration time from the claims and store them in the Database. + """ + @spec create_banned_token(String.t) :: {:ok, Token.t} | {:error, any()} + def create_banned_token(jwt_token) do + case Auth.Guardian.decode_and_verify(jwt_token) do + {:ok, %{"exp" => expires}} -> + create_banned_token(jwt_token, expires) + error -> + error + end + end + + @doc """ + Creates a token entry in the banned tokens table. It receives the token in String format and the + expiration time as Integer. + """ + @spec create_banned_token(String.t, integer()) :: {:ok, Token.t} | {:error, :already_exists} + def create_banned_token(token, expires) do + try do + token + |> Token.new(expires) + |> Repo.insert + rescue + x in CaseClauseError -> x.term + end + end + @doc """ Validates if a JWT token is valid. """ @spec valid_token?(String.t) :: Boolean.t | {:error, :token_expired} def valid_token?(jwt_token) do - with {:ok, claims} <- Auth.Guardian.decode_and_verify(jwt_token), + with false <- token_banned?(jwt_token), + {:ok, claims} <- Auth.Guardian.decode_and_verify(jwt_token), {:ok, user, ^claims} <- Auth.Guardian.resource_from_token(jwt_token), true <- user_active?(user) do @@ -157,6 +196,34 @@ defmodule POABackend.Auth do end end + @doc """ + This function deletes the banned tokens which already expired + """ + @spec purge_banned_tokens() :: :ok + def purge_banned_tokens do + import Ecto.Query, only: [from: 2] + + current_time = :os.system_time(:seconds) + + query = from b in "banned_tokens", + where: b.expires < ^current_time + + Auth.Repo.delete_all(query) + + :ok + end + + @doc """ + Checks if a token is banned or not + """ + @spec token_banned?(String.t) :: Boolean.t + def token_banned?(token) do + case Auth.Repo.get(Token, token) do + nil -> false + _ -> true + end + end + # --------------------------------------- # Private Functions # --------------------------------------- diff --git a/lib/poa_backend/auth/ban_tokens_server.ex b/lib/poa_backend/auth/ban_tokens_server.ex new file mode 100644 index 0000000..db4b8ac --- /dev/null +++ b/lib/poa_backend/auth/ban_tokens_server.ex @@ -0,0 +1,33 @@ +defmodule POABackend.Auth.BanTokensServer do + @moduledoc false + + alias POABackend.Auth + + use GenServer + + @frequency 60 * 60 * 24 * 1000 # one day by default + + def start_link() do + GenServer.start_link(__MODULE__, :noargs, name: __MODULE__) + end + + def init(:noargs) do + frequency = Application.get_env(:poa_backend, :purge_banned_tokens_freq, @frequency) + + set_purge_timer(frequency) + + {:ok, %{frequency: frequency}} + end + + def handle_info(:purge, %{frequency: frequency} = state) do + :ok = Auth.purge_banned_tokens() + + set_purge_timer(frequency) + + {:noreply, state} + end + + defp set_purge_timer(frequency) do + Process.send_after(self(), :purge, frequency) + end +end \ No newline at end of file diff --git a/lib/poa_backend/auth/models/token.ex b/lib/poa_backend/auth/models/token.ex new file mode 100644 index 0000000..08e7dba --- /dev/null +++ b/lib/poa_backend/auth/models/token.ex @@ -0,0 +1,22 @@ +defmodule POABackend.Auth.Models.Token do + use Ecto.Schema + + @moduledoc """ + This module encapsulates the _Token_ model + """ + + @primary_key {:token, :string, []} + + schema "banned_tokens" do + field :expires, :integer + + timestamps() + end + + @type t :: %__MODULE__{token: String.t, + expires: Integer.t} + + def new(token, expires) do + %__MODULE__{token: token, expires: expires} + end +end \ No newline at end of file diff --git a/lib/poa_backend/auth/models/user.ex b/lib/poa_backend/auth/models/user.ex index 6b3566a..c7c19b4 100644 --- a/lib/poa_backend/auth/models/user.ex +++ b/lib/poa_backend/auth/models/user.ex @@ -10,7 +10,6 @@ defmodule POABackend.Auth.Models.User do @primary_key {:user, :string, []} schema "users" do - # field :user, :string, primary_key: true field :password_hash, :string field :password, :string, virtual: true field :active, :boolean, default: true diff --git a/lib/poa_backend/auth/router.ex b/lib/poa_backend/auth/router.ex index 57c0d91..0f4d1b2 100644 --- a/lib/poa_backend/auth/router.ex +++ b/lib/poa_backend/auth/router.ex @@ -6,6 +6,8 @@ defmodule POABackend.Auth.Router do alias POABackend.CustomHandler.REST import Plug.Conn + @token_default_ttl {1, :hour} + plug REST.Plugs.Accept, ["application/json", "application/msgpack"] plug Plug.Parsers, parsers: [Msgpax.PlugParser, :json], pass: ["application/msgpack", "application/json"], json_decoder: Poison plug :match @@ -17,7 +19,7 @@ defmodule POABackend.Auth.Router do [user_name, password] <- String.split(decoded64, ":"), {:ok, user} <- Auth.authenticate_user(user_name, password) do - {:ok, token, _} = POABackend.Auth.Guardian.encode_and_sign(user) + {:ok, token, _} = POABackend.Auth.Guardian.encode_and_sign(user, %{}, ttl: @token_default_ttl) {:ok, result} = %{token: token} @@ -91,6 +93,32 @@ defmodule POABackend.Auth.Router do end end + post "/blacklist/token" do + with {"authorization", "Basic " <> base64} <- List.keyfind(conn.req_headers, "authorization", 0), + {:ok, decoded64} <- Base.decode64(base64), + [admin_name, admin_password] <- String.split(decoded64, ":"), + true <- conn.params["token"] != nil, + {:ok, :valid} <- Auth.authenticate_admin(admin_name, admin_password) + do + case Auth.valid_token?(conn.params["token"]) do + false -> + send_resp(conn, 404, "") + true -> + {:ok, _} = Auth.create_banned_token(conn.params["token"]) + send_resp(conn, 200, "") + end + else + false -> + conn + |> send_resp(404, "") + |> halt + _error -> + conn + |> send_resp(401, "") + |> halt + end + end + match _ do send_resp(conn, 404, "") end diff --git a/lib/poa_backend/auth/supervisor.ex b/lib/poa_backend/auth/supervisor.ex index 65ae677..671da51 100644 --- a/lib/poa_backend/auth/supervisor.ex +++ b/lib/poa_backend/auth/supervisor.ex @@ -20,7 +20,8 @@ defmodule POABackend.Auth.Supervisor do children = [ supervisor(Auth.Repo, []), - Plug.Adapters.Cowboy.child_spec(scheme: rest_options[:scheme], plug: Auth.Router, options: cowboy_options) + Plug.Adapters.Cowboy.child_spec(scheme: rest_options[:scheme], plug: Auth.Router, options: cowboy_options), + worker(Auth.BanTokensServer, []) ] opts = [strategy: :one_for_one] diff --git a/priv/repo/migrations/20180809170931_create_banned_tokens.exs b/priv/repo/migrations/20180809170931_create_banned_tokens.exs new file mode 100644 index 0000000..01b461c --- /dev/null +++ b/priv/repo/migrations/20180809170931_create_banned_tokens.exs @@ -0,0 +1,12 @@ +defmodule POABackend.Auth.Repo.Migrations.CreateBannedTokens do + use Ecto.Migration + + def change do + create_if_not_exists table(:banned_tokens, primary_key: false) do + add :token, :string, primary_key: true + add :expires, :integer + + timestamps() + end + end +end diff --git a/test/ancillary/utils.ex b/test/ancillary/utils.ex index adf97a9..8a9dd10 100644 --- a/test/ancillary/utils.ex +++ b/test/ancillary/utils.ex @@ -3,6 +3,7 @@ defmodule POABackend.Ancillary.Utils do def clear_db do :mnesia.clear_table(:users) + :mnesia.clear_table(:banned_tokens) end end \ No newline at end of file diff --git a/test/auth/api_test.exs b/test/auth/api_test.exs index 8454140..94d65d0 100644 --- a/test/auth/api_test.exs +++ b/test/auth/api_test.exs @@ -434,6 +434,161 @@ defmodule Auth.APITest do assert {404, :nobody} == result end + # ---------------------------------------- + # /blackmail/token Endpoint Tests + # ---------------------------------------- + + test "Ban a token correctly [JSON]" do + mime_type = "application/json" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@user <> ":" <> @password)} + ] + + {200, %{"token" => jwt_token}} = + %{:'agent-id' => "agentID"} + |> Poison.encode!() + |> post(@base_url <> "/session", headers) + + assert Auth.valid_token?(jwt_token) + + blacklist_url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> @admin_pwd)} + ] + + {200, :nobody} = + %{:token => jwt_token} + |> Poison.encode!() + |> post(blacklist_url, headers) + + refute Auth.valid_token?(jwt_token) + end + + test "Ban a token correctly [MSGPACK]" do + mime_type = "application/msgpack" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@user <> ":" <> @password)} + ] + + {200, %{"token" => jwt_token}} = + %{:'agent-id' => "agentID"} + |> Msgpax.pack!() + |> post(@base_url <> "/session", headers) + + assert Auth.valid_token?(jwt_token) + + blacklist_url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> @admin_pwd)} + ] + + {200, :nobody} = + %{:token => jwt_token} + |> Msgpax.pack!() + |> post(blacklist_url, headers) + + refute Auth.valid_token?(jwt_token) + end + + test "Ban an invalid token [JSON]" do + mime_type = "application/json" + url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> @admin_pwd)} + ] + + result = + %{:token => "badtoken"} + |> Poison.encode!() + |> post(url, headers) + + assert result == {404, :nobody} + end + + test "Ban an invalid token [MSGPACK]" do + mime_type = "application/msgpack" + url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> @admin_pwd)} + ] + + result = + %{:token => "badtoken"} + |> Msgpax.pack!() + |> post(url, headers) + + assert result == {404, :nobody} + end + + test "Ban token with wrong Admin credentials [JSON]" do + mime_type = "application/json" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@user <> ":" <> @password)} + ] + + {200, %{"token" => jwt_token}} = + %{:'agent-id' => "agentID"} + |> Poison.encode!() + |> post(@base_url <> "/session", headers) + url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> "wrongpassword")} + ] + + result = + %{:token => jwt_token} + |> Poison.encode!() + |> post(url, headers) + + assert {401, :nobody} == result + end + + test "Ban token with wrong Admin credentials [MSGPACK]" do + mime_type = "application/msgpack" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@user <> ":" <> @password)} + ] + + {200, %{"token" => jwt_token}} = + %{:'agent-id' => "agentID"} + |> Msgpax.pack!() + |> post(@base_url <> "/session", headers) + url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> "wrongpassword")} + ] + + result = + %{:token => jwt_token} + |> Msgpax.pack!() + |> post(url, headers) + + assert {401, :nobody} == result + end + + test "Ban token without token field [JSON]" do + mime_type = "application/json" + url = @base_url <> "/blacklist/token" + headers = [ + {"Content-Type", mime_type}, + {"authorization", "Basic " <> Base.encode64(@admin <> ":" <> @admin_pwd)} + ] + + result = post("", url, headers) + + assert {404, :nobody} == result + end + # ---------------------------------------- # Internal functions # ---------------------------------------- diff --git a/test/auth/auth_test.exs b/test/auth/auth_test.exs index 252b348..a3a6c49 100644 --- a/test/auth/auth_test.exs +++ b/test/auth/auth_test.exs @@ -13,6 +13,10 @@ defmodule Auth.AuthTest do [] end + # ---------------------------------------- + # User Tests + # ---------------------------------------- + test "create a new user" do alias Comeonin.Bcrypt user_name = "ferigis" @@ -90,4 +94,56 @@ defmodule Auth.AuthTest do refute user_name == user_name2 end + + # ---------------------------------------- + # Token Tests + # ---------------------------------------- + + test "create a banned token" do + # first we need a user + user_name = "ferigis" + password = "mypassword" + + {:ok, user} = Auth.create_user(user_name, password) + + # generate a token + {_, jwt_token, _} = Auth.Guardian.encode_and_sign(user) + + {:ok, _token} = Auth.create_banned_token(jwt_token) + {:error, :already_exists} = Auth.create_banned_token(jwt_token) + end + + test "create a banned token with a non JWT token" do + {:error, %ArgumentError{}} = Auth.create_banned_token("wrongJWTToken") + end + + test "delete expired banned tokens" do + current_time = :os.system_time(:seconds) + + {:ok, token1} = Auth.create_banned_token("token1", current_time - 1000) + {:ok, token2} = Auth.create_banned_token("token2", current_time + 1000) + {:ok, token3} = Auth.create_banned_token("token3", current_time - 1000) + {:ok, token4} = Auth.create_banned_token("token4", current_time - 1000) + + all = Auth.Repo.all(Auth.Models.Token) + + assert 4 == length(all) + assert Enum.member?(all, token1) + assert Enum.member?(all, token2) + assert Enum.member?(all, token3) + assert Enum.member?(all, token4) + + # sending a message to the process which cleans the DB + POABackend.Auth.BanTokensServer + |> Process.whereis() + |> send(:purge) + + Process.sleep(1000) # wait a little until the server cleans the DB + + all = Auth.Repo.all(Auth.Models.Token) + + assert 1 == length(all) + assert Enum.member?(all, token2) + end + end \ No newline at end of file