diff --git a/config/test.exs b/config/test.exs index b5b439d..5ca12e4 100644 --- a/config/test.exs +++ b/config/test.exs @@ -28,3 +28,7 @@ config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +# Decrease the number of rounds used to encrypt passwords and whatnot +# to improve performance +config :bcrypt_elixir, :log_rounds, 4 diff --git a/lib/hostas/denizen.ex b/lib/hostas/denizen.ex index 1e8d454..a6ed224 100644 --- a/lib/hostas/denizen.ex +++ b/lib/hostas/denizen.ex @@ -18,5 +18,13 @@ defmodule Hostas.Denizen do denizen |> cast(attrs, [:name, :handle, :password]) |> validate_required([:name, :handle, :password]) + |> unique_constraint(:handle) + |> hash_password end + + # Hash the password + defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do + change(changeset, Bcrypt.add_hash(password)) + end + defp hash_password(changeset), do: changeset end diff --git a/lib/hostas/token.ex b/lib/hostas/token.ex index 5260c9a..7211f8d 100644 --- a/lib/hostas/token.ex +++ b/lib/hostas/token.ex @@ -5,6 +5,7 @@ defmodule Hostas.Token do schema "tokens" do field :denizen_id, :integer field :expires, :utc_datetime + field :token, :string # Link tokens to denizens belongs_to :denizens, Hostas.Denizen diff --git a/lib/hostas_web/controllers/auth/token_controller.ex b/lib/hostas_web/controllers/auth/token_controller.ex index 495d582..c8c9298 100644 --- a/lib/hostas_web/controllers/auth/token_controller.ex +++ b/lib/hostas_web/controllers/auth/token_controller.ex @@ -1,7 +1,79 @@ defmodule HostasWeb.Auth.TokenController do + import Ecto.Query, only: [from: 2] use HostasWeb, :controller - def index(_conn, _params) do + alias Hostas.Repo + alias Hostas.Denizen + alias Hostas.Token + + @doc """ + Generates an API token. Responds with the token if the user + provides the correct password + """ + def create(conn, %{"handle" => handle, "password" => given_password}) do + case Repo.one(from d in Denizen, + where: d.handle == ^handle, + select: %{id: d.id, password: d.password}) do + nil -> + conn + |> put_status(404) + |> json(%{"error" => "No user with handle #{handle}"}) + + denizen -> + %{id: denizen_id, password: real_password_hash} = denizen + + if Bcrypt.verify_pass(given_password, real_password_hash) do + # Create a random token + token = Base.encode64(:crypto.strong_rand_bytes(256)) + + # Calculate when the token should expire + {:ok, time_now} = DateTime.now("Etc/UTC") + expiry = DateTime.add(time_now, 30, :day) + |> DateTime.truncate(:second) + + # Register the token + {:ok, token_struct} = Repo.insert( + %Token{denizen_id: denizen_id, token: token, expires: expiry}) + + conn + |> put_status(201) + |> json(Map.take(token_struct, [:token, :expires])) + else + # Reject the request, as passwords don't match + conn + |> put_status(401) + |> json(%{"error" => "Password mismatch"}) + end + end + end + + def create(conn, params) do + conn + |> put_status(422) + |> json(params) + # |> json(%{"error" => "Missing required parameters"}) + end + + @doc """ + Responds with 200 OK if the requester's `Bearing` header + contains a valid, non-expired API token + """ + def verify(_conn, _params) do + :ok + end + + @doc """ + Deletes the token the requester used in the `Bearing` header + """ + def revoke(_conn, _params) do + :ok + end + + @doc """ + Deletes the token the requester used in the `Bearing` header + and responds with a new one if the old one was valid and unexpired + """ + def renew(_conn, _params) do :ok end end diff --git a/lib/hostas_web/router.ex b/lib/hostas_web/router.ex index d1f0e39..fee567f 100644 --- a/lib/hostas_web/router.ex +++ b/lib/hostas_web/router.ex @@ -23,7 +23,11 @@ defmodule HostasWeb.Router do scope "/hostapi/", HostasWeb do pipe_through :api - resources "/auth/token", Auth.TokenController + # create, verify, renew, revoke + post "/auth/token", Auth.TokenController, :create + get "/auth/token", Auth.TokenController, :verify + delete "/auth/token", Auth.TokenController, :revoke + get "/auth/token/renew", Auth.TokenController, :renew end # Enable LiveDashboard and Swoosh mailbox preview in development diff --git a/mix.exs b/mix.exs index 685fbb0..fa7cdb3 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,8 @@ defmodule Hostas.MixProject do {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, - {:plug_cowboy, "~> 2.5"} + {:plug_cowboy, "~> 2.5"}, + {:bcrypt_elixir, "~> 3.0"} ] end diff --git a/mix.lock b/mix.lock index 819aa63..824b827 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,8 @@ %{ + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.7", "77de20ac77f0e53f20ca82c563520af0237c301a1ec3ab3bc598e8a96c7ee5d9", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2768b28bf3c2b4f788c995576b39b8cb5d47eb788526d93bd52206c1d8bf4b75"}, + "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, diff --git a/priv/repo/migrations/20230607164029_add_token_field.exs b/priv/repo/migrations/20230607164029_add_token_field.exs new file mode 100644 index 0000000..ede0900 --- /dev/null +++ b/priv/repo/migrations/20230607164029_add_token_field.exs @@ -0,0 +1,9 @@ +defmodule Hostas.Repo.Migrations.AddTokenField do + use Ecto.Migration + + def change do + alter table(:tokens) do + add :token, :string + end + end +end