Skip to content

Instantly share code, notes, and snippets.

@nikneroz
Last active October 10, 2023 19:13
Show Gist options
  • Save nikneroz/ba698a120bc037801f45328855cbdca8 to your computer and use it in GitHub Desktop.
Save nikneroz/ba698a120bc037801f45328855cbdca8 to your computer and use it in GitHub Desktop.
Elixir + Phoenix Framework + Guardian + JWT. This is tutorial and step by step installation guide.

Elixir + Phoenix Framework + Guardian + JWT + Comeonin

Preparing environment

We need to generate secret key for development environment.

mix phoenix.gen.secret
# ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf

Let's generate User model and controller.

mix ecto.create
mix phoenix.gen.json User users email:string name:string phone:string password_hash:string is_admin:boolean
mix ecto.migrate

Guardian requires serializer for JWT token generation, so we need to create it lib/my_app_name/token_serializer.ex. You need to restart your server, after adding files to lib folder.

defmodule MyAppName.GuardianSerializer do
  @behaviour Guardian.Serializer

  alias MyAppName.Repo
  alias MyAppName.User

  def for_token(user = %User{}), do: { :ok, "User:#{user.id}" }
  def for_token(_), do: { :error, "Unknown resource type" }

  def from_token("User:" <> id), do: { :ok, Repo.get(User, id) }
  def from_token(_), do: { :error, "Unknown resource type" }
end

After that we need to add Guardian configuration. Add guardian base configuration to your config/config.exs

config :guardian, Guardian,
  allowed_algos: ["HS512"], # optional
  verify_module: Guardian.JWT,  # optional
  issuer: "MyAppName",
  ttl: { 30, :days },
  allowed_drift: 2000,
  verify_issuer: true, # optional
  secret_key: "ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf", # Insert previously generated secret key!
  serializer: MyAppName.GuardianSerializer

Add guardian dependency to your mix.exs

defp deps do
  [
    # ...
    {:guardian, "~> 0.14"},
    # ...
  ]
end

Fetch and compile dependencies

mix do deps.get, compile  

Guardian is ready!

Model authentication part

User tweaks

Now we need to add users path to our API routes.

defmodule MyAppName.Router do
  # ...
  scope "/api/v1", MyAppName do
    pipe_through :api

    resources "/users", UserController, except: [:new, :edit]
  end
  # ...
end

Next step is to add validations to web/models/user.ex. Virtual :password field will exist in Ecto structure, but not in the database, so we are able to provide password to the model’s changesets and, therefore, validate that field.

defmodule MyAppName.User do
  # ...
  schema "users" do
    field :email, :string
    field :name, :string
    field :phone, :string
    field :password, :string, virtual: true # We need to add this row
    field :password_hash, :string
    field :is_admin, :boolean, default: false

    timestamps()
  end
  # ...
end

Validations and password hashing

Add comeonin dependency to your mix.exs

#...
def application do
  [applications: [:comeonin]] # Add comeonin to OTP application
end
# ...
defp deps do
  [
    # ...
    {:comeonin, "~> 3.0"} # Add comeonin to dependencies
    # ...
  ]
end

Now we need to edit web/models/user.ex, add validations for [:email, password] and integrate password hash generation. Also we need separate changeset functions for internal usage and API registration.

defmodule MyAppName.User do
  #...
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :name, :phone, :password, :is_admin])
    |> validate_required([:email, :name, :password])
    |> validate_changeset
  end

  def registration_changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :name, :phone, :password])
    |> validate_required([:email, :name, :phone, :password])
    |> validate_changeset
  end

  defp validate_changeset(struct) do
    struct
    |> validate_length(:email, min: 5, max: 255)
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:email)
    |> validate_length(:password, min: 8)
    |> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
    |> generate_password_hash
  end

  defp generate_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
        put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
      _ ->
        changeset
    end
  end
  #...
end

API authentication with Guardian

Let's add headers check in our web/router.ex for further authentication flow.

defmodule MyAppName.Router do
  # ...
  pipeline :api do
    plug :accepts, ["json"]
    plug Guardian.Plug.VerifyHeader
    plug Guardian.Plug.LoadResource
  end

  pipeline :authenticated do
    plug Guardian.Plug.EnsureAuthenticated
  end
  # ...
  scope "/api/v1", MyAppName do
    pipe_through :api

    pipe_through :authenticated # restrict unauthenticated access for routes below
    resources "/users", UserController, except: [:new, :edit]
  end
  # ...
end

Registration

Now we can't get access to /users route without Bearer JWT Token in header. That's why we need to add RegistrationController and SessionController. It's a good time to make commit before further changes.

Let's create RegistrationController. We need to create new file web/controllers/registration_controller.ex. Also we need specific registration_changeset that we declared before inside of web/models/user.ex

defmodule MyAppName.RegistrationController do
  use MyAppName.Web, :controller

  alias MyAppName.User

  def sign_up(conn, %{"user" => user_params}) do
    changeset = User.registration_changeset(%User{}, user_params)

    case Repo.insert(changeset) do
      {:ok, user} ->
        conn
        |> put_status(:created)
        |> put_resp_header("location", user_path(conn, :show, user))
        |> render("success.json", user: user)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(MyAppName.ChangesetView, "error.json", changeset: changeset)
    end
  end
end

Also we need RegistrationView. So, we need to create one more file named web/views/registration_view.ex.

defmodule MyAppName.RegistrationView do
  use MyAppName.Web, :view

  def render("success.json", %{user: user}) do
    %{
      status: :ok,
      message: """
        Now you can sign in using your email and password at /api/sign_in. You will receive JWT token.
        Please put this token into Authorization header for all authorized requests.
      """
    }
  end
end

After that we need to add /api/sign_up route. Just add it inside of API scope.

defmodule MyAppName.Router do
  # ...
  scope "/api", MyAppName do
    pipe_through :api

    post "/sign_up", RegistrationController, :sign_up
    # ...
  end
  # ...
end

It's time to check our registration controller. If you don't know how to write request tests. You can use Postman app. Let's POST /api/sign_up with this JSON body.

{
	"user": {}
}

We should receive this response

{
  "errors": {
    "phone": [
      "can't be blank"
    ],
    "password": [
      "can't be blank"
    ],
    "name": [
      "can't be blank"
    ],
    "email": [
      "can't be blank"
    ]
  }
}

It's good point, but we need to create new user. That's why we need to POST correct payload.

{
	"user": {
		"email": "hello@world.com",
		"name": "John Doe",
		"phone": "033-64-22",
		"password": "MySuperPa55"
	}
}

We must get this response.

{
  "status": "ok",
  "message": "  Now you can sign in using your email and password at /api/v1/sign_in. You will receive JWT token.\n  Please put this token into Authorization header for all authorized requests.\n"
}

Session management

Wow! We've created new user! Now we have user with password hash in our DB. We need to add password checker function in web/models/user.ex.

defmodule MyAppName.User do
  # ...
  def find_and_confirm_password(email, password) do
    case Repo.get_by(User, email: email) do
      nil ->
        {:error, :not_found}
      user ->
        if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
          {:ok, user}
        else
          {:error, :unauthorized}
        end
    end
  end
  # ...
end

It's time to use our credentials for sign in action. We need to add SessionController with sign_in and sign_out actions, so create web/controllers/session_controller.ex.

defmodule MyAppName.SessionController do
  use MyAppName.Web, :controller

  alias MyAppName.User

  def sign_in(conn, %{"session" => %{"email" => email, "password" => password}}) do  
    case User.find_and_confirm_password(email, password) do
      {:ok, user} ->
         {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :api)

         conn
         |> render "sign_in.json", user: user, jwt: jwt
      {:error, _reason} ->
        conn
        |> put_status(401)
        |> render "error.json", message: "Could not login"
    end
  end  
end

Good! Next step is to add SessionView in web/views/session_view.ex.

defmodule MyAppName.SessionView do
  use MyAppName.Web, :view

  def render("sign_in.json", %{user: user, jwt: jwt}) do
    %{
      status: :ok,
      data: %{
        token: jwt,
        email: user.email
      },
      message: "You are successfully logged in! Add this token to authorization header to make authorized requests."
    }
  end
end

Add some routes to handle sign_in action in web/router.ex.

defmodule MyAppName.Router do
  use MyAppName.Web, :router
  #...
  scope "/api/v1", CianExporter.API.V1 do
    pipe_through :api

    post "/sign_up", RegistrationController, :sign_up
    post "/sign_in", SessionController, :sign_in # Add this line

    pipe_through :authenticated
    resources "/users", UserController, except: [:new, :edit]
  end
  # ...
end

Ok. Let's check this stuff. POST /api/sign_in with this params.

{
	"session": {
		"email": "hello@world.com",
		"password": "MySuperPa55"
	}
}

We should receive this response

{
  "status": "ok",
  "message": "You are successfully logged in! Add this token to authorization header to make authorized requests.",
  "data": {
    "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJVc2VyOjEiLCJleHAiOjE0OTgwMzc0OTEsImlhdCI6MTQ5NTQ0NTQ5MSwiaXNzIjoiQ2lhbkV4cG9ydGVyIiwianRpIjoiZDNiOGYyYzEtZDU3ZS00NTBlLTg4NzctYmY2MjBiNWIxMmI1IiwicGVtIjp7fSwic3ViIjoiVXNlcjoxIiwidHlwIjoiYXBpIn0.HcJ99Tl_K1UBsiVptPa5YX65jK5qF_L-4rB8HtxisJ2ODVrFbt_TH16kJOWRvJyJIoG2EtQz4dXj7tZgAzJeJw",
    "email": "hello@world.com"
  }
}

Now. You can take this token and add it to Authorization: Bearer #{token} header.

@lvillasica
Copy link

If you want Authorization: Bearer #{token} with the Bearer realm, then update the VerifyHeader plug to:
plug Guardian.Plug.VerifyHeader, realm: "Bearer"

@coderberry
Copy link

coderberry commented Jul 1, 2018

This is a great post. I'm running into this issue when attempting to submit a POST to /api/sign_up

** (RuntimeError) 'error_handler' not set in Guardian pipeline

In your example, you do not have an error handler set. Thoughts?

@PaulAGH
Copy link

PaulAGH commented Jul 5, 2018

Great post indeed! Thank you. To add some additional details to Ivillasica's comment. The documentation for Guardian.Plug.Verifier says the realm: "Bearer" is the default if nothing is specified. In practice however, I could not get Guardian.Plug.EnsureAuthenticated to authenticate a token with the "Bearer" realm without explicitly specifying realm: "Bearer" as described by Ivillasica.

@santiagorl229
Copy link

Hello I am following the steps for this manual and i have problem in this line Repo.get_by(User, email: email) more specific in Session management. Would you help me for this error:

[error] #PID<0.433.0> running Guardianprueba.Endpoint (cowboy_protocol) terminated
Server: 0.0.0.0:4000 (http)
Request: POST /api/sign_in
** (exit) an exception was raised:
** (UndefinedFunctionError) function Repo.get_by/2 is undefined (module Repo is not available)
Repo.get_by(User, [email: "hello@world.com"])
(guardianprueba) web/models/user.ex:56: Guardianprueba.User.find_and_confirm_password/2
(guardianprueba) web/controllers/sessioncontroller.ex:7: Guardianprueba.SessionController.sign_in/2
(guardianprueba) web/controllers/sessioncontroller.ex:1: Guardianprueba.SessionController.action/2
(guardianprueba) web/controllers/sessioncontroller.ex:1: Guardianprueba.SessionController.phoenix_controller_pipeline/2
(guardianprueba) lib/guardianprueba/endpoint.ex:1: Guardianprueba.Endpoint.instrument/4
(phoenix) lib/phoenix/router.ex:278: Phoenix.Router.call/1
(guardianprueba) lib/guardianprueba/endpoint.ex:1: Guardianprueba.Endpoint.plug_builder_call/2
(guardianprueba) lib/plug/debugger.ex:122: Guardianprueba.Endpoint."call (overridable 3)"/2
(guardianprueba) lib/guardianprueba/endpoint.ex:1: Guardianprueba.Endpoint.call/2
(plug) lib/plug/adapters/cowboy/handler.ex:16: Plug.Adapters.Cowboy.Handler.upgrade/4
(cowboy) /home/santiago/Documentos/PruebaGuardian/guardianprueba/deps/cowboy/src/cowboy_protocol.erl:442: :cowboy_protocol.execute/4

Very thank's ... I will for you answer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment