auth_template/README.md
2019-12-11 15:56:22 +01:00

8.8 KiB

Auth tutorial for setting up basic user auth with put_session()

Deps used

# Admin auth
{:basic_auth, "~> 2.2.2"},
# Password hashing
{:bcrypt_elixir, "~> 2.0"}

Setup

Start off by running mix deps.get

User Schema

Make sure that you have some context that is like Accounts.User with email:unique and password_hash and that you have Accounts module with get_user!(id)... etc.

You can do this with

terminal:~/tutorial/$ mix phx.gen.html Accounts User users email:string:unique password_hash:string

Add resources "/users", UserController, singleton: true to the router.ex

Password hashing

After that open up accounts/user.ex and add line bellow to changeset()

def changeset(user, attrs) do
  ...
  |> update_change(:password_hash, &Bcrypt.hash_pwd_salt/1)
end

Auth helper

In /tutorial_web/ create folder /helpers and create file auth.ex

defmodule TutorialWeb.Helpers.Auth do
  import Plug.Conn, only: [get_session: 2]
  alias Tutorial.{Repo, Accounts.User}

  def signed_in?(conn) do
    user_id = get_session(conn, :current_user_id)
    if user_id, do: !!Repo.get(User, user_id)
  end
end

Then open your tutorial_web.ex and inside add this import, we are doing this because we want to have this function in every view

def view do
  quote do
    ...
    import TutorialWeb.Helpers.Auth, only: [signed_in?: 1]
  end
end

Session templates/view/router/controller

Templates

Create /session folder in /templates and inside create 2 files sign-in.html.eex and sign-up.html.eex One will be used for Signing in and the other for Signing out

/session/sign-in.html
<%= form_for @conn, Routes.session_path(@conn, :create_session),[as: :session] , fn f -> %>

  <%= label f, :email %>
  <%= text_input f, :email %>
  <%= error_tag f, :email %>

  <%= label f, :password %>
  <%= password_input f, :password_hash %>
  <%= error_tag f, :password_hash %>

  <div>
    <%= submit "Sign in" %>
  </div>
<% end %>

/session/sign-up.html
<%= form_for @changeset, Routes.session_path(@conn, :create_user) , fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :email %>
  <%= text_input f, :email %>
  <%= error_tag f, :email %>

  <%= label f, :password %>
  <%= password_input f, :password_hash %>
  <%= error_tag f, :password_hash %>

  <div>
    <%= submit "Sign up" %>
  </div>
<% end %>

Views

Create session_view.ex in /views folder

defmodule TutorialWeb.SessionView do
  use TutorialWeb, :view
end

Router

under you scope "/" add following

scope "/", TutorialWeb do
  pipe_through :browser
  ...
  # Sign in
  get "/sign-in", SessionController, :sign_in
  post"/sign-in", SessionController, :create_session

  # Sign up
  get "/sign-up", SessionController, :sign_up
  post"/sign-up", SessionController, :create_user

  # Sign out
  post "/sign-out", SessionController, :sign_out
end

Controller

Create session_controller.ex in /controllers

defmodule TutorialWeb.SessionController do
  use TutorialWeb, :controller

  alias Tutorial.Accounts
  alias Accounts.User

  def sign_in(conn, _params) do
    # If user is logged in and tries to connecto to "/sign-in" redirect him
    if is_logged?(conn) do
      redirect(conn, to: Routes.user_path(conn, :show))
    else
      render(conn, "sign-in.html")
    end
  end

  def sign_up(conn, _params) do
    # If user is logged in and tries to connecto to "/sign-up" redirect him
    if is_logged?(conn) do
      redirect(conn, to: Routes.user_path(conn, :show))
    else
      changeset = Accounts.change_user(%User{})
      render(conn, "sign-up.html", changeset: changeset)
    end
  end

  defp is_logged?(conn), do: !!get_session(conn, :current_user_id)

  def create_session(conn, %{"session" => auth_params} = _params) do
    user = Accounts.get_by_email(auth_params["email"])

    case Bcrypt.check_pass(user, auth_params["password_hash"]) do
      {:ok, user} ->
        conn
        |> put_session(:current_user_id, user.id)
        |> put_flash(:info, "Sign in, successful!")
        |> redirect(to: Routes.user_path(conn, :show))

      {:error, _} ->
        conn
        |> put_flash(:error, "Invalid e-mail/password. Try again!")
        |> redirect(to: Routes.session_path(conn, :sign_in))
    end
  end

  def create_user(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_session(:current_user_id, user.id)
        |> put_flash(:info, "Sign up, successful!")
        |> redirect(to: Routes.user_path(conn, :show))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "sign-up.html", changeset: changeset)
    end
  end

  def sign_out(conn, _params) do
    conn
    |> delete_session(:current_user_id)
    |> put_flash(:info, "Signed out.")
    |> redirect(to: Routes.page_path(conn, :index))
  end
end

Update accounts.ex

Remember when we wrote user = Accounts.get_by_email(auth_params["email"]) we need to define that fn Open accounts.ex and add this fn after get_user!(id)

  def get_by_email(nil), do: nil

  def get_by_email(email), do: User |> Repo.get_by(email: email)

NOTE We will use advantage of pattern matching and create two fn-s just in case we get nil

Update user_controller.ex

Part 1 (update params)

In UserController show, edit, update and delete second param is trying to pattern match for %{"id" => id} but since we are going to get user data from conn.asssigns.current_user go ahead and replace %{"id" => id} with _params

def FN(conn, %{"id" => id}) do
  ...
end
# CHANGE TO
def FN(conn, _params) do
  ...
end

Do that for all 4 of them

Part 2 (update user data)

We are still in UserController and fn's show, edit, update and delete are using that id that we just removed, to obtain user data. We are going to change that now

# Change
user = Accounts.get_user!(id)
# Into
user = conn.assigns.current_user

Set conn.assigns.current_user

Now we need to assign current_user to @conn. But how? Read more to find out. :)

Setting up Auth Plug

You remember that /helpers folder that we created earlier? Open it and create new file inside, called plug_auth.ex and put this code inside.

defmodule TutorialWeb.Helpers.AuthGuest do
  import Plug.Conn
  import Phoenix.Controller
  alias Tutorial.Accounts

  def init(default), do: default

  def call(conn, _opts) do
    user_id = get_session(conn, :current_user_id)
    auth_reply(conn, user_id)
  end

  defp auth_reply(conn, nil) do
    conn
    |> put_flash(:error, "You have to sign in first!")
    |> redirect(to: "/sign-in")
    |> halt()
  end

  defp auth_reply(conn, user_id) do
    user = Accounts.get_user!(user_id)

    conn
    |> assign(:current_user, user)
  end
end

After that add this code into your router.ex

  pipeline :auth do
    plug TutorialWeb.Helpers.AuthGuest
  end

And add that pipeline in pipe_through for scope you want to require logged user For example:

scope "/", TutorialWeb do
  pipe_through [:browser, :auth]
  resources "/users", UserController, singleton: true
end

User template

After adding all of this, few things need to be considered.

  • singleton: true

    • We don't want our user to have acces to the list of ALL USERS so we need to remove links that point to: Routes.user_path(@conn, :index). Lets give that power only to admin.
  • UserController fn-s new and create

    • Again we don't want our user to be able to use these functions that are pre-generated by mix phx.gen.html so we should delete those files, controller functions and limit them in router.ex. Update existing resources tag with following. resources "/users", UserController, only: [:show, :edit, :update], singleton: true
  • Update UserController fn's with redirect

    • Some fn's where you redirect to path that doesn't take user as params but you still pass it because of mix phx.gen.html. You just need to remove that user.

And finally lets open /layout/app.html.eex and add following

<%= if signed_in?(@conn) do %>
  <%= link "Sign out", to: Routes.session_path(@conn, :sign_out), method: :post %>
<% else %>
  <%= link "Sign in", to: Routes.session_path(@conn, :sign_in), method: :get %>
  |
  <%= link "Sign up", to: Routes.session_path(@conn, :sign_up), method: :get %>
<% end %>

mix phx.server

Yep you read it, just run your app now and it should most of it gucci. :) except the html.eex files that you didn't change :P