LoginSignup
6
1

More than 5 years have passed since last update.

ElmとPhoenixでJWT認証を行う

Last updated at Posted at 2018-01-23
※※※この記事は古くなったので最新環境(Phoenix1.3+Guardian1.0)で書き直しました =====> Phoenix1.3+Guardian1.0でJWT - Qiita

  鬱蒼としたブナの広葉樹林を俯きながらひたすら歩いた。まだ山科(やましな)は過ぎずや。まだ頂は見えないのだろうか? どこをどう歩いているのか全くわからない。自問自答を繰り返して、試行錯誤に疲れ果て、遂にはあきらめかけたとき、ふっと視界がひらける。目の前に澄み切った青空が広がる。一気に開放感の電撃が身体中を駆け巡る。頂に上って見下ろせば、自分が歩いてきた道程がハッキリ理解できる。サーバ側の設定を終えてElmをやるのはそんな気分ですね。

 今回のテーマは、ElmとPhoenixでJWT認証を行う、です。前回はサーバ側のPhoenixでJWTを扱うアプリを作成しました。「PhoenixのGuardianでJWTを扱う - Qiita」。今回はクライアント側のElmでJWTを扱います。elm-jwtというパッケージを使います。

 以下のサイトがelm-jwtの公式サイトで、exampleもついています。nodeバージョンとphoenixバージョンです。実はこのexampleが、奥手の私がphoenixを知ったきっかけです。(いまさらnodeというのもなーって感じで。)。しかしサーバ側は少し古いのとそのまま動かせなかったので参考程度にしました。クライアント側はサブセットを試しました。
Elm helpers for working with Jwt tokens.

1.プログラムの説明

 今回作るプログラムは、ログインフォームの画面です。「PhoenixのGuardianでJWTを扱う - Qiita」で作成したユーザ名とパスワードでログインするものです。ログインすると取得したJWT文字列とDecode結果を出力します(赤い枠のところ)。あわせて「ユーザ一覧表示」というボタンを表示します。ボタンをクリックするとユーザ一覧を表示します。ユーザ一覧取得のときにtokenをリクエストヘッダーにつけてサーバ側に要求していますので、認証ではじかれません。以下のような画面になります。

image.png

 一方、curlコマンドで同じリクエストを出してもPhoenixのrouterの設定で認証エラーとなり弾かれます。

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

Unauthenticated

2.Phoenixの設定

 前述したように、サーバ側のPhoenixは以下の記事のとおりです。
「PhoenixのGuardianでJWTを扱う - Qiita」

3.Elmの環境設定

 PhoenixでElmを使う時の設定は、以下の記事に従います。ここで簡単になぞっていきます。
「ElmのバックエンドとしてPhoenixを使う - Qiita」

 Brunchでelmコードをコンパイルするプラグインのelm-brunchをインストールし、brunch-config.jsを編集し、web/elmディレクトリで必要なパッケージをインストールします。

npm install --save-dev elm-brunch

vi brunch-config.js

mkdir web/elm
cd web/elm

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

4.Elmコードの作成

 まずweb/templates/page/index.html.eexを修正して、以下のように一行だけにします。

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

 次にweb/static/js/app.jsを編集して、以下の2行を末尾に追加します。

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

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

web/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はゴチャゴチャとしていて見通しが悪いです。やはり別ファイルにして隠蔽したほうがよいですね。我が家の恥です!!! しかし考えてみるとパーサもそうなのですが、このように小さな関数を組み合わせて大きな仕事をさせるのは、関数型言語の醍醐味とも言えるわけで前向きに考えましょう。少し慣れてきたし。

web/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 =
    { id : String
    , username : String
    , iat : Int
    , expiry : Int
    }

tokenStringDecoder =
    field "token" string

dataDecoder : Decoder String
dataDecoder =
    field "data" string

data2Decoder : Decoder String
data2Decoder =
    succeed "success"

tokenDecoder =
    oneOf
        [ nodeDecoder
        , phoenixDecoder
        ]

nodeDecoder =
    map4 JwtToken
        (field "id" string)
        (field "username" string)
        (field "iat" int)
        (field "exp" int)

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

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 ) ]

        ---

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

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

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

 次に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時に少し面倒になります。入れ子になった部分を更新するために以下のようなコーディングになってしまいます。何かよい手があるのだろうか?

                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.eyJhdWQiOiJVc2VyOjM....

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

token
Ok { id = "User:3", username = "User:3", iat = 1516682653, expiry = 1519274653 }

 これで今回は終了です

6
1
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
6
1