以前にElmクライアントとPhoenixサーバ間で、Jwt認証を行うようなプログラムを書きましたが、PheonixとGuardianのバージョンが少し古かったので書き直しました。今回はPhoenix1.3+Guardian1.0の環境で設定を確認しました。Elmのクライアントプログラムはほとんど変わっていませんが、Guardian1.0の影響によりJwtのDecodeを少し変えました。
前の記事を参照することは避けて自己完結型を目指したせいか、冗長になったきらいがあります。しかしPhoenixもGuardianも、最新の情報が少ないので、少しでも実験結果を載せた方が良いかな、と考えた次第です。
1.プログラムの説明
今回の実証のために作成したプログラムの説明を行います。
まずElmクライアントで、ログインフォームの画面を作りました。ログインすると取得したJWT文字列とDecode結果を出力します(赤い枠のところ)。あわせて「ユーザ一覧表示」というボタンを表示します。ボタンをクリックするとユーザ一覧を表示します。ユーザ一覧取得のときにtokenをリクエストヘッダーにつけてサーバ側に要求していますので、認証が無事通ることの検証になっています。以下のような画面になります。
サーバは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を以下のように修正します。
#
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を取得して返しています。
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 :phx13_gdn10, Phx13Gdn10.Guardian,
issuer: "phx13_gdn10",
secret_key: "3Diutd6wRXvOORpXRsEwI9DdC13wCc5WEYauZjyLqGVWnZbjOR4YpM5954Vgm5Tc"
#
mix.exsの deps に guardian を追加します。
#
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とします。以下のようになります。
#
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を追加します。
#
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に対する処理を追加します。
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コマンドで自動生成されたものです。
#
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は関係ありません。
#
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を修正します。
#
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が含まれていることが確認できます。
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を作成します。
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を作って、エラーハンドラーを定義します。
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を追加します
#
def deps do
#
{:poison, "~> 3.1"}
#
end
#
poisonをインストールします
mix deps.get
Authenticationの仕上げに、router.exに2か所追加をします。
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されなくとも、自動的にロードされます。
#
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プログラムが動作する場所を確保しました。
<div id="elm-container"></div>
次にassets/js/app.jsを編集して、末尾に以下の2行を追加します。Elm.Mainは lib/phoenixv13_elm_test_web/elm/vendor/main.jsの中のオブジェクトですが、前述の通り、特にmain.jsを明示的にimportする必要はないようです。
const elmDiv = document.querySelector("#elm-container")
const elmApp = Elm.Main.embed(elmDiv)
次にMain.elmを作成しますが、これはApp.elmをimportしているだけです。
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はゴチャゴチャとしていて見通しが悪いです。しかし考えてみるとパーサもそうなのですが、このように小さな関数を組み合わせて大きな仕事をさせるのは、関数型言語の醍醐味とも言えるわけです。
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データを受け取っていることに対応しています。
#
def sign_in(conn, %{"session" => %{"email" => email, "password" => password}}) do
#
続いて、以下の行に注目します。
|> authenticate authUrl tokenStringDecoder
authenticate関数は、JWTパッケージで以下のように定義されていて、 login credentialsに基づいてHttp.Request を作ります。
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は以下のように化け化けですね。
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOi...
しかしこれをdecodeTokenで解読すると、JWT tokenの以下のような属性が取り出せます。結構感動です。JWTは優れものですね。
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]
(https://qiita.com/sand/items/5581497972473e308f05)
Phoenix1.3の基本的な仕組み - Qiita
Phoenixのログイン管理のためのSessionの使い方 - Qiita
Phoenix1.3のUserアカウントとSession - Qiita
Phoenix1.3+Guardian1.0でJWT - Qiita