[#45] Endpoint for blacklist tokens

This commit is contained in:
Felipe Ripoll 2018-08-09 16:08:44 -06:00
parent 7d24f6807f
commit 2cfd02b664
11 changed files with 378 additions and 13 deletions

View File

@ -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)

View File

@ -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
# ---------------------------------------

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -3,6 +3,7 @@ defmodule POABackend.Ancillary.Utils do
def clear_db do
:mnesia.clear_table(:users)
:mnesia.clear_table(:banned_tokens)
end
end

View File

@ -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
# ----------------------------------------

View File

@ -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