読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Phoenix入門2 - Phonixで認証機能

phoenix elixer

f:id:nipe880324:20151011193641p:plain:w420

前回の記事は「Phonix環境のセットアップから、静的ページを作成し、表示」させました。
今回の記事では、「Phoneixで認証機能を実装」します。
それを通して、マイグレーション、モデル、外部ライブラリ利用などのWebアプリケーションを作るための基本的な箇所を説明します。
次回は「チャット機能を実装」します。


サンプル

動作確認


1. ユーザ登録機能

ユーザモデルの作成

まず、最初にユーザモデルを作成します。
メールとパスワードでログインをできるようにし、パスワードは平文ではなくハッシュ化してデータベースに保存します。
モデルやマイグレーションファイルを作成するコマンドはmix phoenix.gen.modelです。

$ mix phoenix.gen.model User users email:string crypted_password:string
* creating priv/repo/migrations/20151010202152_create_user.exs
* creating web/models/user.ex
* creating test/models/user_test.exs

mix phoenix.gen.modelの引数で、
Userはモデル名、usersはテーブル名、emailcrypted_passwordはフィールド名、stringはデータ型です。


作成されたマイグレーションファイルにnull制約(null: false)とunique制約(unique_index)をつけます。

# priv/repo/migrations/YYYYMMDDhhmmdd_create_user.exs
defmodule ChatPhoenix.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    # usersテーブルを作成
    create table(:users) do
      # emailとcrypted_passwordフィールドをstring型でnull制約で作成
      add :email, :string, null: false
      add :crypted_password, :string, null: false

      # created_atとupdated_atフィールドを作成
      timestamps
    end

    # emailフィールドにunique制約をつける
    create unique_index(:users, [:email])
  end
end


マイグレーションを実行し、usersテーブルを作成します。

$ mix ecto.migrate
Compiled web/models/user.ex
Generated chat_phoenix app
17:07:58.973 [info]  == Running ChatPhoenix.Repo.Migrations.CreateUser.change/0 forward
17:07:58.973 [info]  create table users
17:07:59.001 [info]  create index users_email_index
17:07:59.005 [info]  == Migrated in 0.2s||<


ルートの追加

ルーターにユーザーの登録画面と登録処理のルートを追加します。

# web/router.ex

scope "/", ChatPhoenix do
  pipe_through :browser # Use the default browser stack

  ...
  # 登録画面表示(new)と登録処理(create)
  get  "/register", RegistrationController, :new
  post "/register", RegistrationController, :create
end


現在設定されているルートを確認すると次のようになっていると思います。

$ mix phoenix.routes
Generated chat_phoenix app
        page_path  GET   /          ChatPhoenix.PageController :index
       hello_path  GET   /hello     ChatPhoenix.HelloController :index
registration_path  GET   /register  ChatPhoenix.RegistrationController :new
registration_path  POST  /register  ChatPhoenix.RegistrationController :create


ユーザ登録画面へ遷移できるようにするために、ヘッダーにユーザ登録画面へのリンクを追加します。

<!-- web/templates/layout/app.html.eex -->
...
<div class="header">
  <ul class="nav nav-pills pull-right">
    <!-- registration_pathはヘルパーメソッドで、"GET /register"に変換される -->
    <li><%= link "ユーザ登録", to: registration_path(@conn, :new) %></li>
  </ul>
  <span class="logo"></span>
</div>
...


ユーザ登録コントローラーの追加

ルートに追記したRegistrationControllerのnewアクションの"ユーザ登録画面の表示処理"を追加します。

# web/controllers/registration_controller.ex
defmodule ChatPhoenix.RegistrationController do
  use ChatPhoenix.Web, :controller
  alias ChatPhoenix.User

  @doc """
  ユーザ登録画面の表示
  """
  def new(conn, _params) do
    # chnageset関数は、newメソッドのようなもので、Userのデータを返す
    changeset = User.changeset(%User{})
    # renderの第三引数に値を渡すことで、ビューやテンプレートで値を使用できる
    render conn, "new.html", changeset: changeset
  end
end


ユーザ登録ビューの追加

空のビューモジュールを作成します。

# web/views/registration_view.ex
defmodule ChatPhoenix.RegistrationView do
  use ChatPhoenix.Web, :view
end


ユーザ登録画面のテンプレート作成

ユーザ登録画面を作成します。
@changesetとなっている箇所は、コントローラーのrender関数で渡した値です。
詳しくは説明しませんが、フォームやinput要素はこんなふうに記載するんだなと思っていただければと思います。

<!-- web/templates/registration/new.html.eex -->
<h1>ユーザ登録</h1>

<%= form_for @changeset, registration_path(@conn, :create), fn form -> %>
  <%= if form.errors != [] do %>
    <div class="alert alert-danget">
      <p>エラーが発生しました。</p>
      <ul>
        <%= for {attr, message} <- form.errors do %>
          <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <label>メールアドレス</label>
    <%= email_input form, :email, class: "form-control" %>
  </div>

  <div class="form-group">
    <label>パスワード</label>
    <%= password_input form, :password, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= submit "ユーザ登録", class: "btn btn-primary" %>
  </div>
<% end %>


ユーザ登録画面の確認

http://localhost:4000/registerにアクセスすると、次のようにユーザ登録画面が表示されます。
f:id:nipe880324:20151012042356p:plain:w420


ユーザ登録処理の追加

ユーザ登録画面が表示されるようになったので次は、ユーザ登録処理を追加します。
RegistrationControllerにユーザ登録処理のcreateアクションを追加します。

# web/controllers/registration_controller.ex
defmodule ChatPhoenix.RegistrationController do
  ...

  @doc """
  ユーザ登録処理
  """
  def create(conn, %{"user" => user_params}) do
    # フォーム情報user_paramsの値でuserデータを作成
    changeset = User.changeset(%User{}, user_params)

    # ユーザ登録
    case User.create(changeset, ChatPhoenix.Repo) do
      {:ok, user} ->
        # バリデーションに成功した場合、userレコードを作成し、ログインし、"/"にリダイレクト
        conn
        |> put_flash(:info, "ようこそ" <> changeset.params["email"])
        |> redirect(to: "/")
      {:error, changeset} ->
        # バリデーションに失敗した場合、"new.html"を表示
        conn
        |> put_flash(:info, "アカウントを作成できませんでした")
        |> render("new.html", changeset: changeset)
    end
  end
end


Userモデルにvirtual属性、バリデーションの定義

データベースに保存されないpasswordフィールドというvirtualフィールドを追加します。
また、必須のフィールド(@required_fields)として、emailとpasswordに変更します。
最後に、ユニーク制約やフォーマットなどのバリデーションを追加します。

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

  schema "users" do
    field :email, :string
    field :crypted_password, :string
    # passwordフィールドを追加。virtual: trueとすることでデータベースには保存されない
    field :password, :string, virtual: true

    timestamps
  end

  # crypted_passwordの代わりにpasswordに変更
  # @required_fields ~w(email crypted_password)
  @required_fields ~w(email password)
  @optional_fields ~w()

  @doc """
  "model"と"params"に基づいたchangesetを作成する
  "params"がない場合は、invalidなchangesetを返します。

  castはparamsの値でモデルの値を設定
  update_changeは、"email"の値をフィールドの値を小文字に変更
  unique_constraintは、"email"にユニーク制約のバリデーション
  validate_formatは、"email"に"@"が含まれているかバリデーション
  validate_lengthは、"password"が5文字以上であるかバリデーション
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> update_change(:email, &String.downcase/1)
    |> unique_constraint(:email)
    |> validate_format(:email, ~r/@/)
    |> validate_length(:password, min: 5)
  end
end


Userモデルにcreateメソッドの追加

RegistrationControllerで追加した、create関数を作成します。
create関数はusersテーブルにユーザレコードを1件作成します。

# web/models/user.ex
defmodule ChatPhoenix.User do
  ...

  @doc """
  userレコードを1件作成する

  put_changeは、crypated_passwordに値を設定
  insert()は、テーブルにレコードを作成(SQLのinsert文が走る)
  ectoの関数であり、より詳細を知りたい場合は、http://www.phoenixframework.org/docs/ecto-models を参照してください
  """
  def create(changeset, repo) do
    changeset
    |> put_change(:crypted_password, hashed_password(changeset.params["password"]))
    |> repo.insert()
  end

  @doc """
  パスワードをハッシュ値にする
  Comeoninという値をハッシュ化するライブラリを使用しています
  defpはプライベートメソッド
  """
  defp hashed_password(password) do
    Comeonin.Bcrypt.hashpwsalt(password)
  end
end


Comeoninのインストール

comeoninパスワードをハッシュ化するライブラリ)をインストールします。
依存ライブラリを記載するファイルのmix.exscomeoninを追加します。
ライブラリ名とバージョンをTuple(長さがあらかじめ決まっている配列のようなもの)で記載します。

# mix.exs

# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
  [{:phoenix, "~> 1.0.3"},
   {:phoenix_ecto, "~> 1.1"},
   {:postgrex, ">= 0.0.0"},
   {:phoenix_html, "~> 2.1"},
   {:phoenix_live_reload, "~> 1.0", only: :dev},
   {:comeonin, "~> 1.2"},
   {:cowboy, "~> 1.0"}]
end

そして、mix.exsのアプリケーションの依存関係のリストに:comeoninを追加します。

# mix.exs

# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
  [mod: {ChatPhoenix, []},
   applications: [:phoenix, :phoenix_html, :cowboy, :logger,
                  :phoenix_ecto, :postgrex, :comeonin]]
end

そして、comeoninをインストールし、コンパイルします。

mix do deps.get, compile


ユーザ登録処理の確認

http://localhost:4000/registerにアクセスし、「ユーザ登録」ボタンを押すと、
f:id:nipe880324:20151012042428p:plain:w420

ユーザが登録され、ルートページ("/")にリダイレクトされます。
f:id:nipe880324:20151012042444p:plain




3. ログイン・ログアウト機能

ユーザを登録できるようになりましたので、次はログインとログアウト機能を追加します。

ログイン/ログアウトのルートを追加

# web/router.ex
scope "/", ChatPhoenix do
  ...

  # ログイン画面表示(:new)、ログイン処理(create)、ログアウト処理(delete)
  get    "/login",  SessionController, :new
  post   "/login",  SessionController, :create
  delete "/logout", SessionController, :delete
end


コントローラーを追加

SessionControllerを追加し、newアクションを作成します。

# web/controllers/session_controller.ex
defmodule ChatPhoenix.SessionController do
  use ChatPhoenix.Web, :controller
  alias ChatPhoenix.User

  @doc """
  ログイン画面の表示
  """
  def new(conn, _params) do
    render conn, "new.html"
  end
end


ビューの追加

空のSessionViweを追加します。

# web/views/session_view.ex
defmodule ChatPhoenix.RegistrationView do
  use ChatPhoenix.Web, :view
end


ログイン画面のテンプレートの追加

ログイン画面を作成します。

# web/templates/session/new.html.eex
<h1>ログイン</h1>

<%= form_for @conn, session_path(@conn, :create), [name: :session], fn form -> %>
  <div class="form-group">
    <label>メールアドレス</label>
    <%= email_input form, :email, class: "form-control" %>
  </div>

  <div class="form-group">
    <label>パスワード</label>
    <%= password_input form, :password, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= submit "ログイン", class: "btn btn-primary" %>
  </div>
<% end %>


ログイン画面の確認

http://localhost:4000/loginを開くと、ログイン画面が表示されます。
f:id:nipe880324:20151012042535p:plain:w420


ログイン処理アクションの追加

ログイン画面を表示できましたので、ここからは、ログイン処理を追加します。
コントローラーにログイン処理を行うcreateメソッドを追加します。

# web/controllers/session_controller.ex
defmodule ChatPhoenix.SessionController do
  ...

  @doc """
  ログイン処理
  """
  def create(conn, %{"session" => session_params}) do
    # Sessionモジュールのlogin関数でログイン可否を判定する
    case ChatPhoenix.Session.login(session_params, ChatPhoenix.Repo) do
      # ログイン成功の場合、セッションにuser.idを設定し、ホーム("/")にリダイレクトする
      {:ok, user} ->
        conn
        |> put_session(:current_user, user.id)
        |> put_flash(:info, "ログインしました")
        |> redirect(to: "/")
      # errorの場合、ログイン画面を再表示する
      :error ->
        conn
        |> put_flash(:info, "メールアドレスかパスワードが間違っています")
        |> render("new.html")
    end
  end
end

セッションモジュールを追加

createメソッド内で読んでいるlogin関数を実装します。

# web/models/session.ex
defmodule ChatPhoenix.Session do
  alias ChatPhoenix.User

  @doc """
  ログイン処理をする

  get_by関数でUserモデルをemailで取得します
  authenticateが成功すれば、{:ok, user}を返し、失敗すれば:errorを返します
  """
  def login(params, repo) do
    user = repo.get_by(User, email: String.downcase(params["email"]))
    case authenticate(user, params["password"]) do
      true -> {:ok, user}
      _    -> :error
    end
  end

  @doc """
  認証処理をする

  Comeonin.Bcrypt.checkpw関数でpasswordをハッシュ化してデータベースのハッシュ値と比較します
  """
  defp authenticate(user, password) do
    case user do
      nil -> false
      _   -> Comeonin.Bcrypt.checkpw(password, user.crypted_password)
    end
  end
end


ヘルパー関数を追加

Sessionモジュールにヘルパー関数を追加します。
追加するヘルパー関数は、

  • 現在のログインユーザを取得する current_user
  • 現在ログインをしているか確認する logged_in?

です。

# web/models/session.ex
defmodule ChatPhoenix.Session do
  alias ChatPhoenix.User

  ...

  @doc """
  現在のログインユーザを取得するヘルパー関数

  get_session関数で:current_userからidを取得し、idが存在する場合はDBからUser情報を取得します
  """
  def current_user(conn) do
    id = Plug.Conn.get_session(conn, :current_user)
    if id, do: ChatPhoenix.Repo.get(User, id)
  end


  @doc """
  ログインしているかどうかを返すヘルパー関数
  """
  def logged_in?(conn) do
    !!current_user(conn)
  end
end


ヘルパー関数をビューやテンプレートで使えるようにします。
そのためには、web/web.exviewブロック内でimportをします。

# web/web.ex

def view do
  quote do
    use Phoenix.View, root: "web/templates"

    # Import convenience functions from controllers
    import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1]

    # Use all HTML functionality (forms, tags, etc)
    use Phoenix.HTML

    import ChatPhoenix.Router.Helpers
    # Sessionモジュールのcurrent_userとlogged_in?をWebのviewに追加
    import ChatPhoenix.Session, only: [current_user: 1, logged_in?: 1]
  end
end

こうすることで、use ChatPhoenix.Web, :viewの箇所でcurrent_userlogged_in?関数がインポートされるようになるので、ビューやテンプレートでこれらの関数が使えるよになります。

では、実際にlogged_in?関数とcurrent_user関数をテンプレートで使用します。
レイアウトファイルのヘッダー部分をログインしているときと、していないときで表示を修正します。

<!-- web/templates/layout/app.html.eex -->
<div class="header">
  <ul class="nav nav-pills pull-right">
    <%= if logged_in?(@conn) do %>
      <li><%= current_user(@conn).email %></li>
      <li><%= link "ログアウト", to: session_path(@conn, :delete), method: :delete %></li>
    <% else %>
      <li><%= link "ユーザ登録", to: registration_path(@conn, :new) %></li>
      <li><%= link "ログイン", to: session_path(@conn, :new) %></li>
    <% end %>
  </ul>
  <span class="logo"></span>
</div>


ログアウト関数の追加

SessionControllerにログアウト処理のdelete関数を追加します。

# web/controllers/session_controller.ex
defmodule ChatPhoenix.SessionController do
  ...

  @doc """
  ログアウト処理

  delete_sessionでセッション情報を削除し、ホーム("/")にリダイレクトする
  """
  def delete(conn, _) do
    conn
    |> delete_session(:current_user)
    |> put_flash(:info, "ログアウトしました")
    |> redirect(to: "/")
  end
end


セッション情報のcurrent_userがあるとログインしているということなので、最後にユーザ登録時にもログインするようにセッションにcurrent_userを作成するようにします。

# web/controllers/registration_controller.ex
def create(conn, %{"user" => user_params}) do
  ...
  # ユーザ登録
  case User.create(changeset, ChatPhoenix.Repo) do
    {:ok, user} ->
      # バリデーションに成功した場合、userレコードを作成し、ログインし、"/"にリダイレクト
      conn
      |> put_session(:current_user, user.id)
      |> put_flash(:info, "ようこそ" <> changeset.params["email"])
      |> redirect(to: "/")
  ...
end

ログイン/ログアウト処理の確認

ログインやログアウト、ユーザ登録ができることを確認します。

これで簡単ですがPhoenixで認証機能を実装できました。