Programming Phoenix勉強その7

Programming Phoenix勉強その7

その7です。 ここからchapter5です。認証周りをやるらしいです。

パスワードのハッシュ化

まずはパスワードのハッシュ化を行います。必要なライブラリをインストールするために mix.exs に以下のように追記を行います。

...
def application do
  [mod: {Rumbl, []},
   applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
                  :phoenix_ecto, :postgrex, :comeonin]] # comeoninを追加
end
  ...
defp deps do
  [{:phoenix, "~> 1.2.1"},
   {:phoenix_pubsub, "~> 1.0"},
   {:phoenix_ecto, "~> 3.0"},
   {:postgrex, ">= 0.0.0"},
   {:phoenix_html, "~> 2.6"},
   {:phoenix_live_reload, "~> 1.0", only: :dev},
   {:gettext, "~> 0.11"},
   {:cowboy, "~> 1.0"},
   {:comeonin, "~> 2.0"}] # 追加
end

application に対して追加しているのはこのアプリの依存ライブラリを書いているようです。 comeonin とか言うライブラリを追加しています。 リポジトリ を見るとそのまんまパスワードをハッシュ化してくれるライブラリだとわかります。 mix deps.get で追加できたらモデルでこいつを使うように変更してやります。

def changeset(model, params \\ %{}) do
  model
  |> cast(params, [:name, :username]) # 更新予定のパラメータカラムを第三引数でとる(?)
  |> validate_required([:name, :username]) # このリストがcastが返すchangesetに存在するか検証
  |> validate_length(:username, min: 1, max: 20)
end

def registration_changeset(model, params) do
  model
  |> changeset(params)
  |> cast(params, [:password])
  |> validate_required([:password])
  |> validate_length(:password, min: 6, max: 100)
  |> put_pass_hash()
end

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

Ecto の最新版を使っているので書籍と若干異なっています。新しい方の Ecto では cast/4 は推奨されなくなっているようです。 なので、 Phoenixのガイド とか、 Ectoのドキュメント とかを見て適当に修正してます。(このやり方でいいか不安ですが・・・) また、 :empty もWarningになるので、空のハッシュに変えています。 ついでに create アクションで User.changeset の部分を User.registration_changeset に変更します。 ここまでやってMacだとOKでしたが、Windowsだとエラーになりました。

Windowsでのエラー(comeonin)

Windowsから comeonin を使おうとするとコンパイルを促すエラーが出るので ここ を参考にコンパイルします。 ちなみにVisualStudioインストールしてあったので最下部付近にあるVSインストール済みの場合の方法を取っています。

  • VSに付属している開発者コマンドプロンプトを起動します。

  • 開発者コマンドプロンプト上で以下のコマンドを実行しておきます。

> vcvarsall.bat amd64
  • vcvarsall.bat にパスが通ってない場合は、適当にフルパスで指定すればいいと思います。これを行わなくてもコンパイル自体は出来ますが、実行時にエラーになりました。( vcvarsall.bat については MSDN

  • 本プロジェクト( rumbl )のディレクトリまで移動して以下のコマンドを実行します。

rumbl > mix deps.compile

自分の環境ではこれでうまくいきました。

Plugについて

Plug を使ってログイン機能を作る前に Plug についてちょっと掘ります。

  • Plug にはモジュールプラグと関数プラグの二種類が存在する。

  • モジュールプラグは名前の通り幾つかの関数を集めたモジュールのプラグ

  • 関数プラグは関数名をアトムとして指定したプラグ

ログイン機能としてモジュールプラグを作成します。

モジュールプラグ

モジュールプラグとして設定するモジュールには init/1 関数と call/2 関数が必要とされます。 以下は何もしないモジュールプラグの例です。

defmodule NothingPlug do
  def init(opts) do
    opts
  end

  def call(conn, _opts) do
    conn
  end
end

call 関数の引数を見るとわかりますが、モジュールプラグは conn を変換するようです。

Plug.Connについて(conn)

Plug.Conn が持つフィールドについて見てみます。 書籍の方には色々書いてありますが割愛します。 Plug.Connの公式ドキュメント を参照して下さい。ここではリクエストフィールドが持つものだけを見てみます。

  • host

    リクエストのホスト名 ex) www.pragprog.com

  • method

    リクエストのWebメソッド(GETとかPOSTとか)

  • path_info

    パスを分割したリスト

  • req_headers

    リクエストヘッダ

  • scheme

    プロトコル(httpとか)

Webのリクエスト周りに関係するものが存在していることがわかります。

認証プラグの実装

やっと認証用のプラグを実装します。 controllers/auth.ex を以下の内容で実装します。

defmodule Rumbl.Auth do
  import Plug.Conn

  def init(opts) do
    # キーワードリストから:repoの箇所の値を取得する
    # 無ければexception(つまりは必須)
    Keyword.fetch!(opts, :repo)
  end

  def call(conn, repo) do
    user_id = get_session(conn, :user_id)
    user = user_id && repo.get(Rumbl.User, user_id)
    # assignでconnを変更する(importされた関数)
    # これによって:current_userがコントローラやビューで使えるようになる
    assign(conn, :current_user, user)
  end
end

コメント通りなので余り言うことはないです。 initrepo を取得してそれが conn の第二引数に渡されるようです。セッションにあるユーザIDからユーザを取得しています。 パイプラインの流れの一部として処理してほしいので router.ex を以下のように変更します。

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug Rumbl.Auth, repo: Rumbl.Repo # 追加
end

アクセス制限の実装

Plug は出来たのでアクセス制限とログインを作ります。ログインしない限りは :index アクションと :show アクションにアクセス出来ないようにします。 user_controller.ex を以下のように変更します。

defmodule Rumbl.UserController do
  ...
  def index(conn, _params) do
    case authenticate(conn) do
      # 構造体connのhaltedメンバのパターンマッチによる振り分け
      %Plug.Conn{halted: true} = conn ->
        conn
      conn ->
        users = Repo.all(Rumbl.User)
        render conn, "index.html", users: users
    end
  end
  ...
  defp authenticate(conn) do
    # Plugで追加したassignの呼び出しが可能かどうか
    if conn.assigns.current_user do
      conn
    else
      conn
      |> put_flash(:error, "You must be logged in to access that page")
      |> redirect(to: page_path(conn, :index))
      |> halt()
    end
  end
end

先程の Plug で変更した値を authenticate/1 関数で使っています。また、 :index アクションのアクセス時に authenticate 関数で認証済みかチェック掛けています。

authenticate の関数プラグ化

user_controller.exRumbl.Web の直下のあたりに以下を追加します。

plug :authenticate when action in [:index, :show]

また、 index アクションを case 文を使う以前のものに戻しておきます。 authenticate 関数も以下のように2引数にしておきます。

defp authenticate(conn, _opts) do
  # Plugで追加したassignの呼び出しが可能かどうか
  if conn.assigns.current_user do
    conn
  else
    conn
    |> put_flash(:error, "You must be logged in to access that page")
    |> redirect(to: page_path(conn, :index))
    |> halt()
  end
end

_opts を追加しただけです。関数 Plug 化したためです。 Plug をマクロ展開したときの例が出てますが割愛します。

ログインの実装

認証までしか無いので実際のログイン処理を実装します。取り敢えず create アクションでユーザが作成された時に自動でログインするようにしてみます。 先程作った auth.ex に以下の関数を追加します。

def login(conn, user) do
  conn
  |> assign(:current_user, user)
  |> put_session(:user_id, user.id)
  |> configure_session(renew: true) # セッションキーとかを新しくしている(セキュリティのため)
end

次に create アクションでこの関数を呼び出すようにしてやります。

def create(conn, %{"user" => user_params}) do
  changeset = User.registration_changeset(%User{}, user_params)
  case Repo.insert(changeset) do
    {:ok, user} ->
      conn
      |> Rumbl.Auth.login(user) # ユーザを作成したらログイン
      |> put_flash(:info, "#{user.name} created!")
      |> redirect(to: user_path(conn, :index))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

これで完了です。

ログイン画面の実装

ここまででログインするための素材は揃ったので、ログイン/ログアウト画面を作ります。今まで作ったものの合わせ技なので一気に行きます。 session_controller.ex を実装します。

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

  def new(conn, _) do
    render conn, "new.html"
  end

  def create(conn, %{"session" => %{"username" => user, "password" => pass}}) do
    case Rumbl.Auth.login_by_username_add_pass(conn, user, pass, repo: Repo) do
      {:ok, conn} ->
        conn
        |> put_flash(:info, "Welcome back!")
        |> redirect(to: page_path(conn, :index))
      {:error, _reason, conn} ->
        conn
        |> put_flash(:error, "Invalid username/password combination")
        |> render("new.html")
    end
  end

  def delete(conn, _) do
    conn
    |> Rumbl.Auth.logout()
    |> redirect(to: page_path(conn, :index))
  end
end

session_view.ex も作っておきます。内容は割愛します。関数などは定義しなくて良いです。 router.ex も上で作った session_controller.ex 用に追加しておきます。

scope "/", Rumbl do
  pipe_through :browser # Use the default browser stacks.

  get "/", PageController, :index
  resources "/users", UserController, only: [:index, :show, :new, :create]
  resources "/sessions", SessionController, only: [:new, :create, :delete] # 追加
end

usernameとpasswordでログインするための関数とログアウト用の関数を auth.ex に用意しておきます。

...
import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]

...
def logout(conn) do
  configure_session(conn, drop: true)
end

def login_by_username_add_pass(conn, username, given_pass, opts) do
  repo = Keyword.fetch!(opts, :repo)
  user = repo.get_by(Rumbl.User, username: username)

  # 複数の値で分岐しているためcaseではなくcond(caseは与えられた1つの値に対する分岐)
  cond do
    user && checkpw(given_pass, user.password_hash) ->
      {:ok, login(conn, user)}
    user ->
      {:error, :unauthorized, conn}
    true ->
      dummy_checkpw()
      {:error, :not_found, conn}
  end
end

ログイン画面用のテンプレートも作ります。まず session/new.html.eex を以下のように実装します。

<h1>Login</h1>

<%= form_for @conn, session_path(@conn, :create), [as: :session], fn f-> %>
  <div class="form-group">
    <%= text_input f, :username, placeholder: "Username", class: "form-control" %>
  </div>

  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password", class: "form-control" %>
  </div>
  <%= submit "Log in", class: "btn btn-primary" %>
<% end %>

最後に今まで作ったもののリンクを表示します。 layout/app.html.eex を以下のように変更します。

<header class="header">
  <ol class="breadcrumb text-right">
    <!-- assignsで突っ込んだものが使えている -->
    <%= if @current_user do %>
      <li><%= @current_user.username %></li>
      <li>
        <%= link "Log out", to: session_path(@conn, :delete, @current_user),
                            method: "delete" %>
      </li>
    <% else %>
      <li><%= link "Register", to: user_path(@conn, :new) %></li>
      <li><%= link "Log in", to: session_path(@conn, :new) %></li>
    <% end %>
  </ol>
  <span class="logo"></span>
</header>

これでOKなはずです。

まとめ

  • Plug.Conn を使ったセッションやコネクションの管理はスマートだし中身が分かればわかりやすいと思いました。関数の引数に毎回 conn が出てきちゃいますが・・・

  • 本のおかげかもしれませんが余りブラックボックスな部分を残さないよう理解出来ている感があるのが良いです。

  • 関数型だけあってかロジックを関数毎に分離しているのがとても良かったです。書いてて理解し易い気がします。

1記事がやたらと長くなりましたが実験ということで・・・