LoginSignup
21
16

More than 5 years have passed since last update.

Phoenix1.3+Guardian1.0でJWT

Last updated at Posted at 2018-03-29

 以前にElmクライアントとPhoenixサーバ間で、Jwt認証を行うようなプログラムを書きましたが、PheonixとGuardianのバージョンが少し古かったので書き直しました。今回はPhoenix1.3+Guardian1.0の環境で設定を確認しました。Elmのクライアントプログラムはほとんど変わっていませんが、Guardian1.0の影響によりJwtのDecodeを少し変えました。

 前の記事を参照することは避けて自己完結型を目指したせいか、冗長になったきらいがあります。しかしPhoenixもGuardianも、最新の情報が少ないので、少しでも実験結果を載せた方が良いかな、と考えた次第です。

1.プログラムの説明

 今回の実証のために作成したプログラムの説明を行います。

 まずElmクライアントで、ログインフォームの画面を作りました。ログインすると取得したJWT文字列とDecode結果を出力します(赤い枠のところ)。あわせて「ユーザ一覧表示」というボタンを表示します。ボタンをクリックするとユーザ一覧を表示します。ユーザ一覧取得のときにtokenをリクエストヘッダーにつけてサーバ側に要求していますので、認証が無事通ることの検証になっています。以下のような画面になります。

image.png

 サーバはPhoenixで実装しています。ユーザテーブルを作成し、ユーザ登録機能を実装し、ログイン機能を実装しています。ユーザ一覧の取得には認証が必要となります。これらを実装していきます。ユーザ登録画面はありませんが、ユーザテーブルにはREST APIでアクセスできますので、curlコマンドで登録することになります。ではサーバ側から順番に見ていき、最後にElmクライアントを説明します。

2.userテーブルの作成

 プロジェクトを開始して、ユーザテーブルを作成します。mix phx.gen.jsonというGeneraterを使います。これはJSON resource(User)のためのcontrollerやviews, context を自動生成してくれます。

 プロジェクトを開始します。

mix phx.new phx13_gdn10
cd phx13_gdn10
mix ecto.create

 mix phx.gen.jsonを実行します。

mix phx.gen.json Users User users email:string name:string phone:string password_hash:string is_admin:boolean

 第1引数のUsersがcontextで、第2引数のUserがリソース名(単体)、第3引数のusersがリソース名(複数)です。以下のようなメッセージが表示されますので、従います。

Add the resource to your :api scope in lib/phx13_gdn10_web/router.ex:

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


Remember to update your repository by running migrations:

    $ mix ecto.migrate

 まずrouter.exを以下のように修正します。

lib/phx13_gdn10_web/router.ex
#
  scope "/api/v1", Phx13Gdn10Web do
    pipe_through :api
    resources "/users", UserController, except: [:new, :edit]
  end
#

 このresourcesのルートで、以下のようなREST APIが確立します。但し:new と :editについては明示的に除外されています。
https://hexdocs.pm/phoenix/routing.html#resources

user_path  GET     /users           HelloWeb.UserController :index
user_path  GET     /users/:id/edit  HelloWeb.UserController :edit
user_path  GET     /users/new       HelloWeb.UserController :new
user_path  GET     /users/:id       HelloWeb.UserController :show
user_path  POST    /users           HelloWeb.UserController :create
user_path  PATCH   /users/:id       HelloWeb.UserController :update
           PUT     /users/:id       HelloWeb.UserController :update
user_path  DELETE  /users/:id       HelloWeb.UserController :delete

 mix phx.gen.jsonコマンドは以下のファイルを生成してくれます。

lib/phx13_gdn10/users/user/users.ex --- context module ,for the users API
lib/phx13_gdn10/users/user/user.ex --- schema ,with an users table
lib/phx13_gdn10_web/views/user_view.ex --- view
lib/phx13_gdn10_web/controllers/user_controller.ex --- controller

 またmix phx.gen.jsonコマンドは以下のようなmigrationファイルも生成してくれます。

priv/repo/migrations/20180328064415_create_users.exs

 以下のコマンドでmigrationファイルからテーブルを作成します。ここまでで"/api/v1/users"というpathに対するContorollerやView、Schema、tableなどの基本的なフレームが出来上がりです。簡単でいいですね。

mix ecto.migrate

3.Guardian1.0の設定

 PhoenixでJWTを扱うために、ueberauth/guardian ライブラリを使います。

 まずシークレットキーを作成しておきます。

mix phx.gen.secret
3Diutd6wRXvOORpXRsEwI9DdC13wCc5WEYauZjyLqGVWnZbjOR4YpM5954Vgm5Tc

 次に以下のような、Implementation Moduleを実装します。これはserializationをハンドリングします。 ここがGuardian1.0で変わったところでもあります。

 subject_for_tokenはtokenに含めるsubjectの値を返す関数で、token作成時に呼ばれます。何の値でも構わないのですが、一般的にResourceにユニークな値が良いです。ここではuser id(user tableのid)を返します。resource_from_claimsは引数のclaimsの中のsubject(上の場合で言えばuser id)をkeyとして、resourceを取得して返します。ここではsub=idなので、userを取得して返しています。

lib/phx13_gdn10/guardian.ex
defmodule Phx13Gdn10.Guardian do
  use Guardian, otp_app: :phx13_gdn10

  alias Phx13Gdn10.Repo
  alias Phx13Gdn10.Users.User

  def subject_for_token(resource, _claims) do
    {:ok, to_string(resource.id)} 
    # tokenをdecodeすると、tokenの文字列の一部としてsub=xxxとidが表示される。
    # resource_from_claimsの中でclaims["sub"]としてidが取り出されている。
  end

  def resource_from_claims(claims) do
     id = claims["sub"]
     resource = Repo.get(User, id)
     {:ok,  resource}
  end
end

 次にconfigファイルにGuardianの設定を書きます。ここのシークレットキーに最初に生成したものを設定します。以下の行を末尾に追加します。

config/config.exs
#
config :phx13_gdn10, Phx13Gdn10.Guardian,
       issuer: "phx13_gdn10",
       secret_key: "3Diutd6wRXvOORpXRsEwI9DdC13wCc5WEYauZjyLqGVWnZbjOR4YpM5954Vgm5Tc"
#

 mix.exsの deps に guardian を追加します。

mix.exs
  #
  defp deps do
    [
      {:phoenix, "~> 1.3.0"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:guardian, "~> 1.0"}  # これを追加
    ]
  end
  #

 次のコマンドでguardianをインストールします。

mix do deps.get, compile

 以上でGuardianの設定は終了です。

4.Comeoninの設定

 JWTはGuardianで扱いますが、パスワードのハッシュ化などはComeoninで扱います。

 まずphoenix.gen.jsonコマンドで作成したlib/phx13_gdn10/users/user.exを微調整します。テーブルのfieldとしてpassword_hashを作成しました。しかしユーザからの入力は平文のpasswordで受け取りますので、その受け皿として、Elixir structにpasswordの項目を追加します。この項目はテンポラリなものです。入力を受け取りハッシュを計算するまで必要ですが、最終的なテーブルには必要ないものなのでvirtualとします。以下のようになります。

lib/phx13_gdn10/users/user.ex
#
  schema "users" do
    field :email, :string
    field :is_admin, :boolean, default: false
    field :name, :string
    field :password, :string, virtual: true # これを追加
    field :password_hash, :string
    field :phone, :string

    timestamps()
  end
#

 depsにcomeoninを追加します。

mix.exs
#
  defp deps do
    [
      {:phoenix, "~> 1.3.0"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:guardian, "~> 1.0"},
      {:comeonin, "~> 3.0"}   # これを追加
    ]
  end
#

 comeoninをインストールします。

mix deps.get

 以上でComeoninの設定を終わります。

5.ユーザ登録

 パスワードのハッシュ化が可能になったのでユーザ登録に進みたいと思います。この段階で既にユーザ登録を含めて、ユーザに関する基本的なREST APIはphx.gen.jsonコマンドで実装済みです。ここでは入力された平文のパスワードをハッシュ化して保存するような変更を行います。

 lib/phx13_gdn10/users/user.exを修正します。ここではComeoninを使ってpassword_hashを計算し、changesetに保存するように修正します。あわせて必要な入力チェックも行うように修正します。

まずSchemaとChangesに対する処理を追加します。

lib/phx13_gdn10/users/user.ex
defmodule Phx13Gdn10.Users.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :is_admin, :boolean, default: false
    field :name, :string
    field :password, :string, virtual: true
    field :password_hash, :string
    field :phone, :string

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    # |> cast(attrs, [:email, :name, :phone, :password_hash, :is_admin])
    # |> validate_required([:email, :name, :phone, :password_hash, :is_admin])
    |> cast(attrs, [:email, :name, :phone, :password, :is_admin])
    |> validate_required([:email, :name, :phone, :password, :is_admin])
    |> validate_changeset
  end


  defp validate_changeset(user) do
    user
    |> 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

 ここのchangesetはcreate_user/1から以下のようにして呼ばれ、最終結果がDBに挿入されます。このusers.exもphx.gen.jsonコマンドで自動生成されたものです。

lib/phx13_gdn10/users/users.ex
#
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end
#

 さてここまで設定出来たら、ユーザ登録を確かめることができます。まず以下のコマンドでサーバを立ち上げます。

mix phx.server

 次に別ターミナルから、curlコマンドでエラー入力を与えてみましょう。以下のようにエラーが出力されますが、これが望みの結果です。

curl -H 'Content-Type: application/json' -X POST -d '{ "user":{}}' http://localhost:4009/api/v1/users

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

 次に、正常の値を設定して、curlコマンドを打ってみます。status okとmessageを受け取りますが、これも望みの結果です。

curl -H 'Content-Type: application/json' -X POST -d '{"user": {"email": "hello@world.com","name": "John Doe","phone": "033-64-22","password": "MySuperPa55"}}' http://localhost:4009/api/v1/users

{"data":{"phone":"033-64-22","password_hash":"$2b$12$Yb0uNOw998ReNkhJMcQK0u36QIe0jJ2mEicO/Vc.6zBXPzKpnzWIC","name":"John Doe","is_admin":false,"id":1,"email":"hello@world.com"}}

 同じように3個のユーザを登録します。一番最初に phx.gen.jsonで"/users"パスに対するUserリソースを自動生成していました。User contorollerやUser viewが自動生成されていたはずです。データの入力が完了した今のタイミングで以下のコマンドが使えることを確認します。

 curl -X GET "http://localhost:4009/api/v1/users"
{"data":[
{"phone":"033-64-22","password_hash":"xxxxx","name":"John Doe","is_admin":false,"id":1,"email":"hello@world.com"},
{"phone":"033-64-22","password_hash":"xxxxx","name":"John Doe","is_admin":false,"id":2,"email":"hello2@world.com"},
{"phone":"033-64-22","password_hash":"xxxxx","name":"John Doe","is_admin":false,"id":3,"email":"hello3@world.com"}]}

 確かに3人取得できました。
 これでユーザ登録の設定が完了です。

6.ログイン(sign_in -- Session)

 まずログイン処理に必要となる関数を追加します。ログイン時には、Jwt Tokenを生成しクライアント側に返す処理が必要になります。

 まずfind_and_confirm_password/2という関数を作りますが、これはメールとパスワードでユーザ認証を行う関数です。user.exの末尾に追加します。Comeonin.Bcrypt.checkpw(password, user.password_hash)でチェックしてokかerrorかを決めます。このレベルではjwtは関係ありません。

lib/phx13_gdn10/users/user.ex
#
  alias Phx13Gdn10.Repo
  alias Phx13Gdn10.Users.User

  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
#

 さてログインのために "/sign_in"というパスを振り分け、SessionControllerで対応することにします。router.exを修正します。

lib/phx13_gdn10_web/router.ex
  #
  scope "/api/v1", Phx13Gdn10Web do
    pipe_through :api
    post "/sign_in", SessionController, :sign_in  # これを追加

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

 SessionControllerを実装し、sign_in/2を定義します。これは注意点かな、と思うのですが、Phx13Gdn10.Guardian.decode_and_verifyは、このようにフルで指定してください。Guardian.decode_and_verifなどと誤って省略してしまうと、コンパイルは通りますが実行エラーとなり嵌ります。ネットでも嵌っている方(外の人)がいたようなので、要注意です。またコメントアウトしましたけど、decode_and_verify/1で得られたjwtの中身を確認できます。確かにidが含まれていることが確認できます。

lib/phx13_gdn10_web/controllers/session_controller.ex
defmodule Phx13Gdn10Web.SessionController do
  use Phx13Gdn10Web, :controller

  alias Phx13Gdn10.Users.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} =  Phx13Gdn10.Guardian.encode_and_sign(user)
         # {:ok, claims} = Phx13Gdn10.Guardian.decode_and_verify(jwt)
         # IO.inspect(claims)

         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

 次にviewを作成します。

lib/phx13_gdn10_web/views/session_view.ex
defmodule Phx13Gdn10Web.SessionView do
  use Phx13Gdn10Web, :view

  def render("sign_in.json", %{user: user, jwt: jwt}) do
    %{"token": jwt}
  end

  def render("error.json", %{message: msg}) do
    %{"error": msg}
  end
end

 ログイン設定が完了したので、以下のcurlコマンドでログインを行います。tokenが長すぎて見づらいですが、望みの結果が表示されます。tokenを得たブラウザはログイン状態に入ります。次のリクエストからtokenをヘッダーにつけて渡すことになります。

curl -H 'Content-Type: application/json' -X POST -d '{"session": {"email": "hello@world.com","password": "MySuperPa55"}}' http://localhost:4009/api/v1/sign_in
{"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwaHgxM19nZG4xMCIsImV4cCI6MTUyNDY1OTYwMSwiaWF0IjoxNTIyMjQwNDAxLCJpc3MiOiJwaHgxM19nZG4xMCIsImp0aSI6IjI3NGJhYzJiLTc5ZmQtNDY0Yy1iMDE3LWNkZTkyNzRhNzcyZCIsIm5iZiI6MTUyMjI0MDQwMCwic3ViIjoiMSIsInR5cCI6ImFjY2VzcyJ9.8zIqAUrWJzBbXU3zU8hLqjR5gFQWSQYQrlULGUDszNDWNnt5Nyak_T-sqRJ9kIpOxuDzK3lHS1S2M-VHfOBoqQ"}

 念のために、ログイン失敗のケースも見ておきましょう。パスワードを少し変えてログインしてみます。返ってくるエラーメッセージも期待通りです。

 curl -H 'Content-Type: application/json' -X POST -d '{"session": {"email": "hello@world.com","password": "MySuperPa56"}}' http://localhost:4009/api/v1/sign_in
{"error":"Could not login"}

7.アクセス制限(Authorization)

 さて現状では、以下のユーザ一覧取得のコマンドは、非ログイン状態でも成功します。これをログインユーザのみに制限したいと思います。

curl -X GET "http://localhost:4009/api/v1/users"

 ログインした時だけ "/api/v1/users" のアクセスを許可(Autherization)するように変更します。

 Authentication schemeを構築するために、AuthPipeline moduleを作って、関連のあるplugを集めます。またAuthErrorHandler moduleを作って、エラーハンドラーを定義します。

lib/phx13_gdn10/auth_pipeline.ex
defmodule Phx13Gdn10.Guardian.AuthPipeline do
  @claims %{typ: "access"}

  use Guardian.Plug.Pipeline, otp_app: :Phx13Gdn10,
                               module: Phx13Gdn10.Guardian,
                               error_handler: Phx13Gdn10.Guardian.AuthErrorHandler

  plug Guardian.Plug.VerifySession, claims: @claims
  plug Guardian.Plug.VerifyHeader, claims: @claims, realm: "Bearer"
  plug Guardian.Plug.EnsureAuthenticated
  plug Guardian.Plug.LoadResource, ensure: true
end

defmodule Phx13Gdn10.Guardian.AuthErrorHandler do
  import Plug.Conn

  def auth_error(conn, {type, reason}, _opts) do
    body = Poison.encode!(%{message: to_string(type)})
    send_resp(conn, 401, body)
  end
end

 Guardian.Plug.VerifySessionはsessionの中のtokenを見つけ正しいかvalidateします。今回は特にsessionを利用していないので無視されます。Guardian.Plug.VerifyHeaderはAuthorization headerを見て、tokenが存在し改竄が無いことを確認します。一般的に、jwtはidを内包しているだけでなく、改竄チェックが行えるという優良な性質を持っています。またrealm: "Bearer"を明示的に指定する必要があります。これ無しだとElmのJWTパッケージを通してアクセスし他時に認証が通りませんでした。。このような操作を経て、Guardian.Plug.EnsureAuthenticatedは正しいtokenの存在を確認し、存在しなければauth_errorを呼んで:unauthenticatedを返します。Guardian.Plug.LoadResource は改竄チェックを終えたtokenからリソース(user)をロードします

 またここではMapをJSONに変換するためにJSONライブラリのPoisonのPoison.encode!/1関数を使っています。devinus/poison。depsにpoisonを追加します

mix.exs
#
def deps do
#
  {:poison, "~> 3.1"}
#
end
#

poisonをインストールします

mix deps.get

 Authenticationの仕上げに、router.exに2か所追加をします。

lib/phx13_gdn10_web/router.ex
defmodule Phx13Gdn10Web.Router do
  use Phx13Gdn10Web, :router

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

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

  pipeline :authenticated do    # (1)3行追加
    plug Phx13Gdn10.Guardian.AuthPipeline
  end

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

    get "/", PageController, :index
  end

  # Other scopes may use custom stacks.
  scope "/api/v1", Phx13Gdn10Web do
    pipe_through :api
    post "/sign_in", SessionController, :sign_in

    pipe_through :authenticated    # (2)追加 下のusersのみ制限
    resources "/users", UserController, except: [:new, :edit]
  end
end

 以上の制限により、前と同じコマンドを打てば401を表すUnauthenticatedが返ってくるようになりました。ここではuserのcreateにも制限をかけてしまいましたので、現実のプログラムではもう少し細かく制限をかける必要があるかと思われます。

 curl -X GET "http://localhost:4009/api/v1/users"
{"message":"unauthenticated"}

8.Elmクライアント

 最後に「1.プログラムの説明」で述べたクライアント画面を実現していきます。

 しばらくassetsディレクトリで作業します。

cd assets/

 Phoenixにおいてassets(JavaScriptやCSS)の管理はBrunchを使います。Elmを使うのでassets/brunch-config.jsを修正します。2個所追加します。(B)によってElmプログラムがassets/elm/Main.elmであり、jsへのコンパイル結果をassets/vendorディレクトリに吐き出すことを指示しています。一般的にvendorディレクトリはBrunchによってこれ以上変換(コンパイル)されないファイルの置き場所で、そこのファイルは全て、appプログラムの前にロードされ、連結されるという決まりになっています。特にapp.jsから明示的にimportされなくとも、自動的にロードされます。

assets/brunch-config.js
#
  paths: {
    // Dependencies and current project directories to watch
//---------- (A)"elm"を追加
    watched: ["static", "css", "js", "elm",  "vendor"],
//----------
    // Where to compile files to
    public: "../priv/static"
  },

  // Configure your plugins
  plugins: {
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/vendor/]
    },
//---------- (B)elmBrunch項を追加
    elmBrunch: {
      elmFolder: "elm",
      mainModules: ["Main.elm"],
      outputFolder: "../vendor"
    }
//----------
  },
#

 上の(B)のElm拡張をを扱えるように、elm-brunchをインストールします。

npm install --save-dev elm-brunch

 次にelmディレクトリを作成し必要なパッケージをインストールします。

mkdir elm
cd elm
elm-package install elm-lang/html
elm-package install elm-lang/http
elm-package install simonh1000/elm-jwt

 それではPhoenixのトップページのHTMLを修正します。index.html.eexを開いて、中身をすべて削除し、以下の一行に置き換えます。これでElmプログラムが動作する場所を確保しました。

lib/phx13_gdn10_web/templates/page/index.html.eex
<div id="elm-container"></div>

 次にassets/js/app.jsを編集して、末尾に以下の2行を追加します。Elm.Mainは lib/phoenixv13_elm_test_web/elm/vendor/main.jsの中のオブジェクトですが、前述の通り、特にmain.jsを明示的にimportする必要はないようです。

assets/js/app.js
const elmDiv = document.querySelector("#elm-container")
const elmApp = Elm.Main.embed(elmDiv)

 次にMain.elmを作成しますが、これはApp.elmをimportしているだけです。

assets/elm/Main.elm
module Main exposing (main)
import Html
import App exposing (init, update, view)
main =
    Html.program
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }

 今回のElmコードは以下のApp.elmで全てです。ElmのModel-View-Updateパタンは大変見通しがよいのですが、JSON.Decodeはゴチャゴチャとしていて見通しが悪いです。しかし考えてみるとパーサもそうなのですが、このように小さな関数を組み合わせて大きな仕事をさせるのは、関数型言語の醍醐味とも言えるわけです。

assets/elm/App.elm
module App exposing (init, update, view)

import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Task exposing (Task)
import Http
import Json.Encode as E exposing (Value)
import Jwt exposing (..)
import Json.Decode as Json exposing (Decoder, Value, field, int, map4, oneOf, string, succeed, list)


authUrl =
    "/api/v1/sign_in"

-- MODEL
type alias User =
    { email : String
    , name : String
    , phone : String
    , password : String
    , isAdmin : Bool
    }

type alias Model =
    { token : Maybe String
    , inputUser : User
    , currentUser : Maybe User
    , currentUsers : List User
    }    

init : ( Model, Cmd Msg )
init =
    Model Nothing initUser Nothing [] ! []

initUser =
    User "email" "name" "phone" "password" False

-- UPDATE
type Field
    = Femail
    | Fpass

type Msg
    = Login
    | FormInput Field String
    | LoginResult (Result Http.Error String)
    | GetUsers
    | UsersResult (Result JwtError (List User))

update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
    case Debug.log "update" message of
        FormInput inputId val ->
            case inputId of
                Femail ->
                    let
                      oldUser = model.inputUser
                      newUser = { oldUser | email=val }
                    in
                      { model | inputUser = newUser } ! []
                Fpass ->
                    let
                      oldUser = model.inputUser
                      newUser = { oldUser | password=val }
                    in
                      { model | inputUser = newUser } ! []

        Login ->
            model ! [ submitCredentials model ]

        LoginResult res ->
            case res of
                Ok token ->
                    { model | token = Just token} ! []

                Err err ->
                    -- { model | msg = getPhoenixError err } ! []
                    model ! []

        GetUsers ->
              model ! [ getUsers model.token ]

        UsersResult res ->
            case res of
                Ok users ->
                    { model | currentUsers=users} ! []

                Err err ->
                    model ! []


-- COMMANDS
submitCredentials : Model -> Cmd Msg
submitCredentials model =
    E.object
        [ ( "session",   crdentialJson model ) ]
        |> authenticate authUrl tokenStringDecoder
        |> Http.send LoginResult

crdentialJson model =
    E.object
        [ ( "email",    E.string model.inputUser.email )
        , ( "password", E.string model.inputUser.password )
        ]

getUsers : Maybe String -> Cmd Msg
getUsers mtoken =
    case mtoken of
        Nothing ->
            Cmd.none
        Just token ->
            Jwt.get token "/api/v1/users" usersDecoder
            |> Jwt.send UsersResult

-- get : String -> String -> Decoder a -> Request a
-- Decoder (List User) ==> Request (List User)

usersDecoder : Decoder (List User)
usersDecoder =
    field "data" ( Json.list duser )

duser : Decoder User
duser = Json.map3 toUser (field "email" string) (field "name" string) (field "phone" string)

toUser : String -> String -> String -> User
toUser e n p =
    { email=e, name=n, phone=p, password="", isAdmin=False }



-- VIEW
view : Model -> Html Msg
view model =
    div
        [ class "container" ]
        [ h1 [] [ text "elm-jwt with Phoenix backend" ]
        , p [] [ text "username = testuser, password = testpassword" ]
        , div
            [ class "row" ]
            [ Html.form
                [ onSubmit Login
                , class "col-xs-12"
                ]
                [ div []
                    [ div
                        [ class "form-group" ]
                        [ label
                            [ for "email" ]
                            [ text "Email" ]
                        , input
                            [ onInput (FormInput Femail)
                            , class "form-control"
                            , value model.inputUser.email
                            ]
                            []
                        ]
                    , div
                        [ class "form-group" ]
                        [ label
                            [ for "password" ]
                            [ text "Password" ]
                        , input
                            [ onInput (FormInput Fpass)
                            , class "form-control"
                            , value model.inputUser.password
                            ]
                            []
                        ]
                    , button
                        [ type_ "submit"
                        , class "btn btn-default"
                        ]
                        [ text "Login" ]
                    ]
                ]
            ]

        , case model.token of
            Nothing ->
                text ""

            Just tokenString ->
                let
                    token =
                        decodeToken tokenDecoder tokenString
                in
                    div []
                        [ p [] [ text tokenString ]
                        , p [] [ text <| toString token ]
                        , button [ onClick GetUsers ] [ text "ユーザ一覧表示" ]
                        ]

        , case model.currentUsers of
            [] ->
                text ""
            _ ->
                div []
                  [ p [] [text "ユーザ一覧"]
                  , ul [] (List.map viewUsers model.currentUsers)
                  ]
        ]


viewUsers u =
    li []
      [ h3 [] [ text u.email]
      , p  [] [ text u.name]
      , p  [] [ text u.phone]
      ]



-- Decorders
type alias JwtToken =
    { aud : String
    , exp : Int
    , iat : Int
    , sub : String
    }

tokenStringDecoder =
    field "token" string

dataDecoder : Decoder String
dataDecoder =
    field "data" string

data2Decoder : Decoder String
data2Decoder =
    succeed "success"

tokenDecoder =
     nodeDecoder

nodeDecoder =
    map4 JwtToken
        (field "aud" string)
        (field "exp" int)
        (field "iat" int)
        (field "sub" string)

Json.Encode と Json.Ddecode

 ElmでJsonデータ(文字列)を通信で読み込んだり、出力したりするのにJson.Encode と Json.Ddecodeを使います。

import Json.Encode as E exposing (Value)
import Json.Decode as Json exposing (Decoder, Value, field, int, map4, oneOf, string, succeed, list)

 ElmのなかではJson Typeのデータはありませんから、対応するRecordが使われます。つまりJson.DecordはJsonデータを受け取ってElm値に変換するのに使われます。反対にJson.EncordはElm値をJsonデータに変換するのに使われます。

decode : Json data  -> Elm data
encode : Elm data   -> Json data

Json.Decode公式サイト
Json.Encode公式サイト

 例えば、ログインのCredentialsを送信するElmプログラムは、以下のコードでJsonを作成して送っています。

submitCredentials : Model -> Cmd Msg
submitCredentials model =
    E.object
        [ ( "session",   crdentialJson model ) ]
        |> authenticate authUrl tokenStringDecoder
        |> Http.send LoginResult

crdentialJson model =
    E.object
        [ ( "email",    E.string model.inputUser.email )
        , ( "password", E.string model.inputUser.password )
        ]

 これはPhoenix側が以下のコードでJsonデータを受け取っていることに対応しています。

lib/phx13_gdn10_web/controllers/session_controller.ex
#
  def sign_in(conn, %{"session" => %{"email" => email, "password" => password}}) do
#

続いて、以下の行に注目します。

 |> authenticate authUrl tokenStringDecoder

 authenticate関数は、JWTパッケージで以下のように定義されていて、 login credentialsに基づいてHttp.Request を作ります。

Jwt.authenticate
authenticate : String -> Decoder a -> Value -> Request a

 次にJson.Decodeを見ましょう。前の記事で見たようにusersを取得した時は以下のようなJsonデータが返ってきます。

curl -X GET "http://localhost:4000/api/v1/users"

{"data":
  [{"phone":"033-64-22",
    "password_hash":"$2b$12$wIO....",
    "name":"JohnDoe",
    "is_admin":false,
    "id":1,
    "email":"hello@world.com"}]}

 このJsonを読み込むDecoderは以下のようなコードになります。得られるElm値はList User型になります。

usersDecoder : Decoder (List User)
usersDecoder =
    field "data" ( Json.list duser )

duser : Decoder User
duser = Json.map3 toUser (field "email" string) (field "name" string) (field "phone" string)

toUser : String -> String -> String -> User
toUser e n p =
    { email=e, name=n, phone=p, password="", isAdmin=False }

Model

 Userの定義はこんなもので。Modelですが、inputUserはログインフォームの入力で使います。ユーザ登録でも使いたいと思っていましたが、今回は実装していません。currentUserは"/api/v1/users/3"のようなパスで一人のユーザを取得するときに使う予定ですが、今回は実装していないので使いません。currentUsersは"/api/v1/users/"のパスで取得するときのUserのリストです。

type alias User =
    { email : String
    , name : String
    , phone : String
    , password : String
    , isAdmin : Bool
    }

type alias Model =
    { token : Maybe String
    , inputUser : User
    , currentUser : Maybe User
    , currentUsers : List User
    } 

 Modelの初期値は以下のように定義できます。

init : ( Model, Cmd Msg )
init =
    Model Nothing initUser Nothing [] ! []

initUser =
    User "email" "name" "phone" "password" False

 さて、Modelの定義の中にUserを埋め込んでいますが、update時に少し面倒になります。入れ子になった部分を更新するために以下のようなコーディングになってしまいます。何かよい手があるのだろうか?Elixirではネストしたデータ構造を操作するための便利な関数put_inとupdate_inが用意されているのですがね。

                Femail ->
                    let
                      oldUser = model.inputUser
                      newUser = { oldUser | email=val }
                    in
                      { model | inputUser = newUser } ! []

update

 プログラムのメインロジックであるupdateは以下のようになっています。特に目新しいものはありません。

type Field
    = Femail
    | Fpass

type Msg
    = Login -- ログイン処理
    | FormInput Field String -- ログインフォームの入力処理
    | LoginResult (Result Http.Error String) -- ログイン処理の結果処理
    | GetUsers -- ユーザ一覧処理
    | UsersResult (Result JwtError (List User)) -- ユーザ一覧処理の結果処理


update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
    case Debug.log "update" message of
        FormInput inputId val ->
            case inputId of
                Femail ->
                    let
                      oldUser = model.inputUser
                      newUser = { oldUser | email=val }
                    in
                      { model | inputUser = newUser } ! []
                Fpass ->
                    let
                      oldUser = model.inputUser
                      newUser = { oldUser | password=val }
                    in
                      { model | inputUser = newUser } ! []

        Login ->
            model ! [ submitCredentials model ]

        LoginResult res ->
            case res of
                Ok token ->
                    { model | token = Just token} ! []

                Err err ->
                    -- { model | msg = getPhoenixError err } ! []
                    model ! []

        GetUsers ->
              model ! [ getUsers model.token ]

        UsersResult res ->
            case res of
                Ok users ->
                    { model | currentUsers=users} ! []

                Err err ->
                    model ! []

View

 viewのコードはいつものように長いですが、あまり複雑ではありません。

-- VIEW
view : Model -> Html Msg
view model =
    div
        [ class "container" ]
        [ h1 [] [ text "elm-jwt with Phoenix backend" ]
        , p [] [ text "username = testuser, password = testpassword" ]
        , div
            [ class "row" ]
            [ Html.form
                [ onSubmit Login
                , class "col-xs-12"
                ]
                [ div []
                    [ div
                        [ class "form-group" ]
                        [ label
                            [ for "email" ]
                            [ text "Email" ]
                        , input
                            [ onInput (FormInput Femail)
                            , class "form-control"
                            , value model.inputUser.email
                            ]
                            []
                        ]
                    , div
                        [ class "form-group" ]
                        [ label
                            [ for "password" ]
                            [ text "Password" ]
                        , input
                            [ onInput (FormInput Fpass)
                            , class "form-control"
                            , value model.inputUser.password
                            ]
                            []
                        ]
                    , button
                        [ type_ "submit"
                        , class "btn btn-default"
                        ]
                        [ text "Login" ]
                    ]
                ]
            ]

        , case model.token of
            Nothing ->
                text ""

            Just tokenString ->
                let
                    token =
                        decodeToken tokenDecoder tokenString
                in
                    div []
                        [ p [] [ text tokenString ]
                        , p [] [ text <| toString token ]
                        , button [ onClick GetUsers ] [ text "ユーザ一覧表示" ]
                        ]

        , case model.currentUsers of
            [] ->
                text ""
            _ ->
                div []
                  [ p [] [text "ユーザ一覧"]
                  , ul [] (List.map viewUsers model.currentUsers)
                  ]
        ]

viewUsers u =
    li []
      [ h3 [] [ text u.email]
      , p  [] [ text u.name]
      , p  [] [ text u.phone]
      ]

 さて以下のコードに注目してください。

            Just tokenString ->
                let
                    token =
                        decodeToken tokenDecoder tokenString
                in
                    div []
                        [ p [] [ text tokenString ]
                        , p [] [ text <| toString token ]

 実行結果の画面出力を見ると、tokenStringは以下のように化け化けですね。

tokenString
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOi...

 しかしこれをdecodeTokenで解読すると、JWT tokenの以下のような属性が取り出せます。結構感動です。JWTは優れものですね。

token
Ok { aud = "phx13_gdn10", exp = 1524728179, iat = 1522308979, sub = "3" }

 これで今回は終了です

 それではphoenixを起動してみましょう。1章で示した画面を目にします。めでたしです。

mix phx.server

 登録したメールとパスワードです。
"email": "hello@world.com","password": "MySuperPa55"
"email": "hello2@world.com","password": "MySuperPa56"
"email": "hello3@world.com","password": "MySuperPa57"

 SessionとECTO Changesetについてもう少し実験したいと思う。

 今回は以上になります

■ Elixir/Phoenixの基礎についてまとめた過去記事
Elixir Ecto チュートリアル - Qiita
Elixir Ecto のまとめ - Qiita
Elixir Ecto Association - Qiita
Phoenix1.3の基本的な仕組み - Qiita
Phoenixのログイン管理のためのSessionの使い方 - Qiita
Phoenix1.3のUserアカウントとSession - Qiita
Phoenix1.3+Guardian1.0でJWT - Qiita

21
16
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
21
16