LoginSignup
22
11

More than 5 years have passed since last update.

Phoenix (Elixir) + Guardian + Comeonin で簡単なログインを実装してみた

Last updated at Posted at 2017-08-20

インストール

今回はPhoenixのバージョンは1.2.4

Elixirのインストール

brew install elixir

Phoenixのインストール

mix local.hex
mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez

サンプルプロジェクトの新規作成とDB作成

プロジェクト作成

mix phoenix.new my_app
cd my_app
mix ecto.create

UserリソースのScaffold

今回はcomeoninを使ってパスワードをハッシュ化し、ユーザが入力したパスワードとの比較を簡単にできるようにUserモデルのハッシュ化したパスワードを保存するカラム名は
password_hashencrypted_passwordにするのをオススメします!

mix phoenix.gen.html User users email:string encrypted_password:string
mix ecto.migrate
web/router.ex
scope "/", MyApp do
  pipe_through :browser 
  # ...
  resources "/users", UserController
end

関連モジュールのインストールと設定

mix.exs
defp deps do
  [
    # ...
    {:comeonin, "~> 4.0"},
    {:bcrypt_elixir, "~> 0.12.0"},
    {:guardian, "~> 0.14"}
  ]
end

関連モジュールのインストール

mix deps.get

Guardianの設定

公式のドキュメントの設定通りやったのですが、例にあるconfig.exsではうまく動かなかったので、注意してください!

内部的にJOSEを使っているのでそれ向けにconfigをかかたないとだめでした。なので、公式に書かれている内容secret keyの設定をしました。

Secret Keyの生成

iex -S mix phoenix.server
iex(2)> JOSE.JWS.generate_key(%{"alg" => "HS512"}) |> JOSE.JWK.to_map |> elem(1) |> Map.take(["k", "kty"])
config/dev.exs
config :guardian, Guardian,
  allowed_algos: ["HS512"], # optional
  verify_module: Guardian.JWT,  # optional
  issuer: "MyApp",
  ttl: { 30, :days },
  allowed_drift: 2000,
  verify_issuer: true, # optional
  secret_key: %{
    "k" => "SET HERE",
    "kty" => "oct"
  },
  serializer: MyApp.GuardianSerializer

Serializerの実装

lib/my_app/guardian_serializer.ex
defmodule MyApp.GuardianSerializer do
  @behaviour Guardian.Serializer

  alias MyApp.Repo
  alias MyApp.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

ユーザの作成

Userモデルの実装

ユーザを新規作成する時にパスワードをハッシュ化する必要があるので、その実装です。

web/model/user.ex
defmodule MyApp.User do
  use MyApp.Web, :model

  schema "users" do
    field :email, :string
    field :encrypted_password, :string

    field :password, :string, virtual: true

    timestamps()
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :password])
    |> validate_required([:email, :password])
    |> put_change(:encrypted_password, encrypted_password(params["password"]))
  end

  defp encrypted_password(password) do
    if password do
      Comeonin.Bcrypt.hashpwsalt(password)
    end
  end
end

ユーザのフォームの修正

フォームがencrypted_passwordになっているのでpasswordに変更

web/templates/user/form.html.eex
<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <div class="form-group">
    <%= label f, :email, class: "control-label" %>
    <%= text_input f, :email, class: "form-control" %>
    <%= error_tag f, :email %>
  </div>

  <div class="form-group">
    <%= label f, :password, class: "control-label" %>
    <%= text_input f, :password, class: "form-control" %>
    <%= error_tag f, :password %>
  </div>

  <div class="form-group">
    <%= submit "Submit", class: "btn btn-primary" %>
  </div>
<% end %>

画面からユーザを新規作成

mix phoenix.server

http://localhost:4000/users/new にアクセスしメールアドレスとパスワードを入力する

ログイン・ログアウト

ログイン画面の実装

web/models/user.ex
defmodule MyApp.User do
  use MyApp.Web, :model

  schema "users" do
    field :email, :string
    field :encrypted_password, :string

    field :password, :string, virtual: true

    timestamps()
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :password])
    |> validate_required([:email, :password])
    |> put_change(:encrypted_password, encrypted_password(params["password"]))
  end

  defp encrypted_password(password) do
    if password do
      Comeonin.Bcrypt.hashpwsalt(password)
    end
  end
end
web/models/auth.ex
defmodule MyApp.Auth do
  alias MyApp.Repo
  alias MyApp.User

  def valid?(changeset) do
    Repo.get_by(User, email: String.downcase(changeset.changes.email))
    |> Comeonin.Bcrypt.check_pass(changeset.changes.password)
  end
end
web/controllers/session_controller.ex
defmodule MyApp.SessionController do
  use MyApp.Web, :controller

  alias MyApp.User
  alias MyApp.Auth

  def new(conn, _params) do
    changeset = User.changeset(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)
    case Auth.valid?(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "Log In successfully.")
        |> Guardian.Plug.sign_in(user)
        |> redirect(to: user_path(conn, :index))
      {:error, _} ->
        conn
        |> put_flash(:error, "Invalid password or email")
        |> render("new.html", changeset: changeset)
    end
  end

  def delete(conn, %{}) do
    conn
    |> Guardian.Plug.sign_out
    |> put_flash(:info, "Logged out")
    |> redirect(to: login_path(conn, :new))
  end
end
web/router.ex
defmodule MyApp.Router do
  use MyApp.Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :browser_session do
    plug Guardian.Plug.VerifySession # looks in the session for the token
    plug Guardian.Plug.LoadResource
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyApp do
    pipe_through [:browser, :browser_session] # Use the default browser stack

    get "/", PageController, :index

    get "/login", SessionController, :new, as: :login
    post "/login", SessionController, :create, as: :login
    delete "/logout", SessionController, :delete, as: :logout

    resources "/users", UserController
  end

  # Other scopes may use custom stacks.
  # scope "/api", MyApp do
  #   pipe_through :api
  # end
end
web/templates/session/new.html.eex
<h2>Sign In</h2>

<%= render "form.html", changeset: @changeset,
                        action: login_path(@conn, :create) %>
web/templates/session/form.html.eex
<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <div class="form-group">
    <%= label f, :email, class: "control-label" %>
    <%= text_input f, :email, class: "form-control" %>
    <%= error_tag f, :email %>
  </div>

  <div class="form-group">
    <%= label f, :password, class: "control-label" %>
    <%= text_input f, :password, class: "form-control" %>
    <%= error_tag f, :password %>
  </div>

  <div class="form-group">
    <%= submit "Login", class: "btn btn-primary" %>
  </div>
<% end %>
web/views/session_view.ex
defmodule MyApp.SessionView do
  use MyApp.Web, :view
end

ログイン

http://localhost:4000/login にアクセスし先程登録したメールアドレスとパスワードを入力するとログインが成功し、間違ったメールアドレスとパスワードのペアだとエラーになります。

ログインが必要なページの実装

UserControllerへはログインが必要になるように実装します。

web/controllers/user_controller.ex
defmodule MyApp.UserController do
  use MyApp.Web, :controller

  use Guardian.Phoenix.Controller

  alias MyApp.User

  plug Guardian.Plug.EnsureAuthenticated, handler: __MODULE__, typ: "access"

  def index(conn, _params, _user, _claims) do
    users = Repo.all(User)
    render(conn, "index.html", users: users)
  end

  def new(conn, _params, _user, _claims) do
    changeset = User.changeset(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}, _user, _claims) do
    changeset = User.changeset(%User{}, user_params)

    case Repo.insert(changeset) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: user_path(conn, :index))
      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}, _user, _claims) do
    user = Repo.get!(User, id)
    render(conn, "show.html", user: user)
  end

  def edit(conn, %{"id" => id}, _user, _claims) do
    user = Repo.get!(User, id)
    changeset = User.changeset(user)
    render(conn, "edit.html", user: user, changeset: changeset)
  end

  def update(conn, %{"id" => id, "user" => user_params}, _user, _claims) do
    user = Repo.get!(User, id)
    changeset = User.changeset(user, user_params)

    case Repo.update(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User updated successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, changeset} ->
        render(conn, "edit.html", user: user, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}, _user, _claims) do
    user = Repo.get!(User, id)

    # Here we use delete! (with a bang) because we expect
    # it to always work (and if it does not, it will raise).
    Repo.delete!(user)

    conn
    |> put_flash(:info, "User deleted successfully.")
    |> redirect(to: user_path(conn, :index))
  end

  def unauthenticated(conn, _params) do
    conn
    |> put_flash(:error, "Authentication required")
    |> redirect(to: login_path(conn, :new))
  end
end

ログアウト

任意の場所に以下のログアウトボタンを記述すればログアウトができるようになります。

<%= link "Logout", to: logout_path(@conn, :delete), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %>

まとめ

ドキュメントや関連するサンプルのソースを眺めてもはまるところがあったので、簡単ですがまとめてみました。

ElixrやPhoenixはまだまた初心者なので、不備があればツッコミお待ちしています!!

参考にしたページ

Elixir / Phoenixを基礎から学ぶ!

22
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
11