From 8697be9f173faf924fd2505d8441a270f1d52f5a Mon Sep 17 00:00:00 2001 From: KKlochko Date: Sat, 24 Aug 2024 16:16:37 +0300 Subject: [PATCH] Update the API Authentication. --- config/config.exs | 4 ++ lib/link_shortener_web/auth/error_handler.ex | 11 +++ lib/link_shortener_web/auth/guardian.ex | 31 ++++++++ lib/link_shortener_web/auth/pipeline.ex | 9 +++ .../controllers/api/v1/accounts_controller.ex | 26 +++++++ .../controllers/api/v1/accounts_json.ex | 11 +++ .../controllers/fallback_controller.ex | 7 ++ lib/link_shortener_web/router.ex | 3 + mix.exs | 3 +- mix.lock | 2 + .../api/v1/accounts_controller_test.exs | 71 +++++++++++++++++++ test/support/fixtures/accounts_fixtures.ex | 19 +++++ 12 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 lib/link_shortener_web/auth/error_handler.ex create mode 100644 lib/link_shortener_web/auth/guardian.ex create mode 100644 lib/link_shortener_web/auth/pipeline.ex create mode 100644 lib/link_shortener_web/controllers/api/v1/accounts_controller.ex create mode 100644 lib/link_shortener_web/controllers/api/v1/accounts_json.ex create mode 100644 test/link_shortener_web/controllers/api/v1/accounts_controller_test.exs diff --git a/config/config.exs b/config/config.exs index 8c29002..6b29e96 100644 --- a/config/config.exs +++ b/config/config.exs @@ -61,6 +61,10 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :link_shortener, LinkShortenerWeb.Auth.Guardian, + issuer: "link_shortener", + secret_key: System.get_env("SECRET_KEY_BASE") + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/link_shortener_web/auth/error_handler.ex b/lib/link_shortener_web/auth/error_handler.ex new file mode 100644 index 0000000..5351018 --- /dev/null +++ b/lib/link_shortener_web/auth/error_handler.ex @@ -0,0 +1,11 @@ +defmodule LinkShortenerWeb.Auth.ErrorHandler do + import Plug.Conn + + def auth_error(conn, {type, _reason}, _opts) do + body = Poison.encode!(%{error: to_string(type)}) + conn + |> put_resp_content_type("application/json") + |> send_resp(401, body) + end +end + diff --git a/lib/link_shortener_web/auth/guardian.ex b/lib/link_shortener_web/auth/guardian.ex new file mode 100644 index 0000000..72446b9 --- /dev/null +++ b/lib/link_shortener_web/auth/guardian.ex @@ -0,0 +1,31 @@ +defmodule LinkShortenerWeb.Auth.Guardian do + use Guardian, otp_app: :link_shortener + + alias LinkShortener.Accounts + alias LinkShortener.Accounts.User + + def subject_for_token(user, _claims) do + {:ok, to_string(user.id)} + end + + def resource_from_claims(%{"sub" => id}) do + user = Accounts.get_user!(id) + {:ok, user} + rescue + Ecto.NoResultsError -> {:error, :resource_not_found} + end + + def authenticate(email, password) do + with user <- Accounts.get_user_by_email_and_password(email, password) do + case user do + %User{} -> create_token(user) + nil -> {:error, :unauthorized} + end + end + end + + defp create_token(user) do + {:ok, token, _claims} = encode_and_sign(user) + {:ok, user, token} + end +end diff --git a/lib/link_shortener_web/auth/pipeline.ex b/lib/link_shortener_web/auth/pipeline.ex new file mode 100644 index 0000000..51e8332 --- /dev/null +++ b/lib/link_shortener_web/auth/pipeline.ex @@ -0,0 +1,9 @@ +defmodule LinkShortenerWeb.Auth.Pipeline do + use Guardian.Plug.Pipeline, otp_app: :link_shortener, + module: LinkShortenerWeb.Auth.Guardian, + error_handler: LinkShortenerWeb.Auth.ErrorHandler + + plug Guardian.Plug.VerifyHeader + plug Guardian.Plug.EnsureAuthenticated + plug Guardian.Plug.LoadResource +end diff --git a/lib/link_shortener_web/controllers/api/v1/accounts_controller.ex b/lib/link_shortener_web/controllers/api/v1/accounts_controller.ex new file mode 100644 index 0000000..0f7db44 --- /dev/null +++ b/lib/link_shortener_web/controllers/api/v1/accounts_controller.ex @@ -0,0 +1,26 @@ +defmodule LinkShortenerWeb.Api.V1.AccountsController do + use LinkShortenerWeb, :controller + + alias LinkShortener.Accounts + alias LinkShortener.Accounts.User + alias LinkShortenerWeb.Auth.Guardian + + action_fallback LinkShortenerWeb.FallbackController + + def sign_up(conn, %{"user" => user_params}) do + with {:ok, %User{} = user} <- Accounts.register_user(user_params), + {:ok, token, _claims} <- Guardian.encode_and_sign(user) do + conn + |> put_status(:created) + |> render(:user, %{user: user, token: token}) + end + end + + def sign_in(conn, %{"email" => email, "password" => password}) do + with {:ok, user, token} <- Guardian.authenticate(email, password) do + conn + |> put_status(:created) + |> render(:user, %{user: user, token: token}) + end + end +end diff --git a/lib/link_shortener_web/controllers/api/v1/accounts_json.ex b/lib/link_shortener_web/controllers/api/v1/accounts_json.ex new file mode 100644 index 0000000..db8320d --- /dev/null +++ b/lib/link_shortener_web/controllers/api/v1/accounts_json.ex @@ -0,0 +1,11 @@ +defmodule LinkShortenerWeb.Api.V1.AccountsJSON do + alias LinkShortener.Links.Link + + def user(%{user: user, token: token}) do + %{ + id: user.id, + email: user.email, + token: token + } + end +end diff --git a/lib/link_shortener_web/controllers/fallback_controller.ex b/lib/link_shortener_web/controllers/fallback_controller.ex index 4c2c9ec..5950199 100644 --- a/lib/link_shortener_web/controllers/fallback_controller.ex +++ b/lib/link_shortener_web/controllers/fallback_controller.ex @@ -21,4 +21,11 @@ defmodule LinkShortenerWeb.FallbackController do |> put_view(html: LinkShortenerWeb.ErrorHTML, json: LinkShortenerWeb.ErrorJSON) |> render(:"404") end + + def call(conn, {:error, :unauthorized}) do + conn + |> put_status(:unauthorized) + |> put_view(html: LinkShortenerWeb.ErrorHTML, json: LinkShortenerWeb.ErrorJSON) + |> render(:"401") + end end diff --git a/lib/link_shortener_web/router.ex b/lib/link_shortener_web/router.ex index f68abee..935471f 100644 --- a/lib/link_shortener_web/router.ex +++ b/lib/link_shortener_web/router.ex @@ -28,6 +28,9 @@ defmodule LinkShortenerWeb.Router do pipe_through :api scope "/v1", Api.V1, as: :v1 do + post "/users/sign_up", AccountsController, :sign_up + post "/users/sign_in", AccountsController, :sign_in + resources "/links", LinkController end end diff --git a/mix.exs b/mix.exs index 080405f..b6e14d0 100644 --- a/mix.exs +++ b/mix.exs @@ -59,7 +59,8 @@ defmodule LinkShortener.MixProject do {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:guardian, "~> 2.3"} ] end diff --git a/mix.lock b/mix.lock index 3ace9c5..4d46f9c 100644 --- a/mix.lock +++ b/mix.lock @@ -15,9 +15,11 @@ "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, + "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]}, "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, diff --git a/test/link_shortener_web/controllers/api/v1/accounts_controller_test.exs b/test/link_shortener_web/controllers/api/v1/accounts_controller_test.exs new file mode 100644 index 0000000..f355af6 --- /dev/null +++ b/test/link_shortener_web/controllers/api/v1/accounts_controller_test.exs @@ -0,0 +1,71 @@ +defmodule LinkShortenerWeb.Api.V1.AccountsControllerTest do + use LinkShortenerWeb.ConnCase + + import LinkShortener.AccountsFixtures + + alias LinkShortener.Accounts.User + + @create_attrs %{ + email: "user@mail.com", + password: "some password" + } + + @update_attrs %{ + email: "some updated email", + password: "some updated password" + } + + @invalid_password_attrs %{ + email: "user@mail.com", + password: "" + } + + @invalid_attrs %{ + email: nil, + encrypted_password: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "create user with sign up" do + test "renders user when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/v1/users/sign_up", user: @create_attrs) + assert %{ + "email" => "user@mail.com", + "token" => token + } = json_response(conn, 201) + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/v1/users/sign_up", user: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "user sign in" do + setup [:create_user] + + test "renders user when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/v1/users/sign_in", @create_attrs) + + assert %{ + "email" => email, + "token" => token, + } = json_response(conn, 201) + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/v1/users/sign_in", @invalid_password_attrs) + assert %{ + "errors" => %{"detail" => "Unauthorized"} + } = json_response(conn, 401) + end + end + + defp create_user(_) do + user = user_fixture(@create_attrs) + %{user: user} + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index 90afcc1..96bc2e7 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -4,6 +4,13 @@ defmodule LinkShortener.AccountsFixtures do entities via the `LinkShortener.Accounts` context. """ + alias LinkShortener.Accounts + alias LinkShortener.Accounts.User + alias LinkShortenerWeb.Auth.Guardian + + @doc """ + Generate a unique user email. + """ def unique_user_email, do: "user#{System.unique_integer()}@example.com" def valid_user_password, do: "hello world!" @@ -23,6 +30,18 @@ defmodule LinkShortener.AccountsFixtures do user end + def user_token_fixture(attrs \\ %{}) do + user_params = %{ + email: "user@mail.com", + password: "some password" + } + + {:ok, %User{} = user} = Accounts.register_user(user_params) + {:ok, token, _claims} = Guardian.encode_and_sign(user) + + token + end + def extract_user_token(fun) do {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")