🔑

elm-spa v6でユーザ認証実装

2022/04/19に公開

ElmでSPAをするためのボイラプレートを生成してくれるelm-spaのバージョン6がリリースされてから約一年が経ちました。バージョン5を使い続けていたのですが、そろそろ使おうと思いたち、その中でも目玉(?)機能のユーザ認証機能が少し難しく感じたため、解説を書こうと思います。

2つのサンプルプログラムを通してユーザ認証機能について、解説をしようと思います。ElmでSPAをしたり、elm-spaは抽象化されている部分もあるため、中級者・上級者向けの解説となります。

1つ目は、乱数で認証が1/2で成功するユーザ認証のサンプルです。

Image from Gyazo

2つ目は、elm-spa公式のフォーム入力をすることで認証できるユーザ認証のサンプルです。

Image from Gyazo

elm-spa事前知識

elm-spaはSPAに必要なボイラープレートが自動生成をしてくれるツールという話をしました。実際には、プロジェクトルート直下に、.elm-spaという隠しファイルで、defaults, generated, templatesの3つのディレクトリが生成されます。詳しい解説は、別の記事を書く予定ですが、ごく簡単にこれら3つのディレクトリの説明をします。

  • defaults
    • そのまま使うことも、上書きすることも可能なSPAで利用される基本ファイル群
  • generated
    • そのまま使うことが前提なファイル、ルーティングによって自動生成されるファイル群
  • templates
    • ファイルを自動生成するときに使われるテンプレートファイル群
.elm-spa
├── defaults
│   ├── Auth.elm
│   ├── Effect.elm
│   ├── Main.elm
│   ├── Pages
│   │   └── NotFound.elm
│   ├── Shared.elm
│   └── View.elm
├── generated
│   ├── Gen
│   │   ├── Model.elm
│   │   ├── Msg.elm
│   │   ├── Pages.elm
│   │   ├── Params
│   │   │   ├── Home_.elm
│   │   │   └── NotFound.elm
│   │   └── Route.elm
│   ├── Page.elm
│   └── Request.elm
└── templates
    ├── advanced.elm
    ├── element.elm
    ├── sandbox.elm
    └── static.elm

以下がelm.jsonです。source-directoriesにsrcの他に、先程の.elm-spadefaults, generatedのディレクトリが追加されていることがわかります。これらは上にあればあるほど優先度が高いため、同ディレクトリの同ファイル名をsrcに生やすことでdefaultsに定義されているファイルを上書きすることができます。elm-spaのドキュメントを読むと、defaultsからファイルを移動する指示が書かれていることがあります。今回のサンプルでもいくつかのファイルを上書きしています。

{
    ...
    "source-directories": [
        "src",
        ".elm-spa/defaults",
        ".elm-spa/generated"
    ],
    ...
}

ランダム認証のサンプル

src以下のファイルは以下になります。何が上書きされているファイルなのかの確認をしてみてください。簡単に各ファイルの説明をします。

  • Pages(ページ・ルーティングごとに生えるファイル)
    • Home_.elm(/ サインイン成功したときに遷移するファイル)
    • NotFound.elm(/not-found サインイン失敗ときに遷移するファイル)
  • Auth(認証に関する処理を書いたファイル)
  • Shared(すべてのページがアクセスする必要のあるデータを管理するファイル)
src
├── Auth.elm
├── Pages
│   ├── Home_.elm
│   └── NotFound.elm
└── Shared.elm

このアプリケーションで、 http://localhost:1234/ に対してアクセスをすると以下の手順で認証と成否判定を行い画面遷移を行います。少し複雑ですが、この流れについて説明をしていこうと思います。

まずは、.elm-spa/defaults/Main.elmのinit関数からになります、細かな部分の理解はしませんが、SharedとPagesのinitを呼び出し、MainモジュールのModelとCmdとして、マージして生成しています。

init : Shared.Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
    let
        ( shared, sharedCmd ) =
            Shared.init (Request.create () url key) flags

        ( page, effect ) =
            Pages.init (Route.fromUrl url) shared url key
    in
    ( Model url key shared page
    , Cmd.batch
        [ Cmd.map Shared sharedCmd
        , Effect.toCmd ( Shared, Page ) effect
        ]
    )

次にsrc/Shared.elmのinit関数です。もし、ルーティングが http://localhost:1234/ のとき、乱数0 or 1を発生させ、0 なら False, 1 なら True となる乱数を生成しRandomSignIn Msgを呼び出します。それ以外のルーティングであれば、副作用は発生しません。

randomBool : Random.Generator Bool
randomBool =
    Random.map ((==) 1) (Random.int 0 1)


randomSignIn : Cmd Msg
randomSignIn =
    Random.generate RandomSignIn randomBool


init : Request -> Flags -> ( Model, Cmd Msg )
init req _ =
    ( { user = Nothing }
    , if req.route == Gen.Route.Home_ then
        randomSignIn

      else
        Cmd.none
    )
        ChangedUrl url ->
            if url.path /= model.url.path then
                let
                    ( page, effect ) =
                        Pages.init (Route.fromUrl url) model.shared url model.key
                in
                ( { model | url = url, page = page }
                , Effect.toCmd ( Shared, Page ) effect
                )

            else
                ( { model | url = url }, Cmd.none )

Sharedのupdate関数のRandomSignInの分岐では、成功すれば仮のユーザtype alias Model = { user: Maybe () } を保持します。そうでなければ、Nothingを保持します。また、成功の場合は、Home_へ遷移します。

update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update req msg model =
    case msg of
        SignIn ->
            ( model, randomSignIn )

        RandomSignIn isSucceeded ->
            if isSucceeded then
                ( { model | user = Just () }, Request.replaceRoute Gen.Route.Home_ req )

            else
                ( { model | user = Nothing }, Request.replaceRoute Gen.Route.NotFound req )

        SignOut ->
            ( { model | user = Nothing }, Request.replaceRoute Gen.Route.NotFound req )

ここからelm-spa V6のユーザ認証に関わるポイントです。各ページのmain関数に当たる部分がpage関数になりますが、 ./src/Pages/Home.elm のpageでは、Page.protected.advancedという関数にuserを受け取り、各基本関数をセットしたレコードを返しています。protectedは認証によって保護された、つまりサインイン後のページで使われます。advancedはSharedモジュールのMsg等を直接呼び出したいページに使われます。

page : Shared.Model -> Request.With Params -> Page.With Model Msg
page _ _ =
    Page.protected.advanced <|
        \user ->
            { init = init
            , update = update
            , view = view
            , subscriptions = subscriptions
            }

./elm-spa/generated/Page.elmのprotected関数を見ると、beforeInit = Auth.beforeProtectedInitという項目があります。これが認証時に呼ばれる関数を指しています。

protected =
    ElmSpa.protected
        { effectNone = Effect.none
        , fromCmd = Effect.fromCmd
        , beforeInit = Auth.beforeProtectedInit
        }

./src/Auth.elmbeforeProtectedInit関数を見てみると、Shared.Modelのuserの有無によってパターンマッチが行われています。Justのときは認証後のページにuserが渡されます。これはまさに、先程のHome_.elmのpage関数のuserに当たります。Nothingのときは、NotFoundへリダイレクトされます。

beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit shared _ =
    case shared.user of
        Just user ->
            ElmSpa.Provide user

        Nothing ->
            ElmSpa.RedirectTo Gen.Route.NotFound

認証の流れは以上ですが、サインイン失敗ページから再びサインインするときには、SharedのSignIn Msgを呼び出しています。EffectとはCmdを抽象化した型で、自ページのCmdとSharedのCmdを抽象化し、うまく扱うための型になります。ここでは、SharedのCmdを自ページのCmdとして扱うためにadvancedのページとして定義しています。

update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
    case msg of
        SignIn ->
            ( model, Effect.fromShared Shared.SignIn )


view : Model -> View Msg
view model =
    { title = "サインイン失敗"
    , body =
        [ p [] [ text "サインイン失敗" ]
        , button [ onClick SignIn ] [ text "サインイン再チャレンジ" ]
        ]
    }

フォーム入力による認証のサンプル

今回のファイルの主要ファイル群は以下になります。先程のサンプルと差異がある部分のみピックアップし説明をします。

  • ./src/Storage.elm(ローカルストレージを扱うためのportsや読み書きのための定義が書かれるファイル)
  • ./src/Domain/User.elm(認証に使われるユーザ情報が書かれるファイル)
  • ./public/main.js(Elmの起動とローカルストレージの実装が書かれるファイル)
src
├── Auth.elm
├── Domain
│   └── User.elm
├── Pages
│   ├── Home_.elm
│   └── SignIn.elm
├── Shared.elm
├── Storage.elm
└── UI.elm

public
├── dist
│   └── elm.js
├── index.html
└── main.js

先程のサンプルではメモリ上のユーザの状態によってサインインを保持していましたが、今回は./public/main.jsでローカルストレージを利用してユーザの状態を管理しています。よって、flagsとportsを利用していることがわかります。portsのコールバック関数では、ローカルストレージへの書き込みと、Elm側にストレージの読み込みを指示していることがわかります。

const app = Elm.Main.init({
  flags: JSON.parse(localStorage.getItem('storage'))
})

app.ports.save_.subscribe(storage => {
  localStorage.setItem('storage', JSON.stringify(storage))
  app.ports.load_.send(storage)
})

./src/Pages/SignIn.elmでサインイン処理を見てみましょう。フォームのサブミット時に、Storageモジュールを通して、先程のports関数setItemを呼び出すことが想像できます。

update : Storage -> Msg -> Model -> ( Model, Cmd Msg )
update storage msg model =
    case msg of
        UpdatedName name ->
            ( { model | name = name }
            , Cmd.none
            )

        SubmittedSignInForm ->
            ( model
            , Storage.signIn { name = model.name } storage
            )


view : Model -> View Msg
view model =
    { title = "Sign in"
    , body =
        UI.layout
            [ Html.form [ Events.onSubmit SubmittedSignInForm ]
                [ Html.label []
                    [ Html.span [] [ Html.text "Name" ]
                    , Html.input
                        [ Attr.type_ "text"
                        , Attr.value model.name
                        , Events.onInput UpdatedName
                        ]
                        []
                    ]
                , Html.button [ Attr.disabled (String.isEmpty model.name) ]
                    [ Html.text "Sign in" ]
                ]
            ]
    }

ローカルストレージの読み込みのportsは、./src/Shared.elmを見るとわかります。subscriptionして、サインインページにいれば、Home_へ遷移します。

update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update req msg model =
    case msg of
        StorageUpdated storage ->
            ( { model | storage = storage }
            , if Gen.Route.SignIn == req.route then
                Request.pushRoute Gen.Route.Home_ req

              else
                Cmd.none
            )


subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
    Storage.load StorageUpdated

./src/Pages/Home_.elmpage関数を見ると、先程のサンプル同じくprotectedが付けられています。elementはCmd呼び出しはありますが、Effectは使わない場合のページの種類になります。

page : Shared.Model -> Request -> Page.With Model Msg
page shared _ =
    Page.protected.element <|
        \user ->
            { init = init
            , update = update shared.storage
            , view = view user
            , subscriptions = \_ -> Sub.none
            }

最後に、./src/Auth.elmbeforeProtectedInit関数を見てみると、Shared.Modelstorage.userが有る無しで画面の遷移を制御しています。

beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit { storage } _ =
    case storage.user of
        Just user ->
            ElmSpa.Provide user

        Nothing ->
            ElmSpa.RedirectTo Gen.Route.SignIn

2つの認証サンプルからわかること

2つのサンプルを見てきました。ここからわかる共通点とelm-spaにおける認証機構のポイントは、以下の3つであることがわかります。

  • Page.protected.~ 関数による認証のガード。
  • Auth.elmによるフロントエンドにおける認証と認可。
  • Shared.elmによる認証情報の管理と実装。

認証のガードは、画面単位で認証を通して欲しいか、通して欲しくないかを選択ができます。Auth.elmでは、以下のインターフェースでSharedのModelの有無によって認証が行われます。認証が通れば、User情報(など)を認可情報として画面に渡すことができます。

beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route

Shared.elmでは、認証をするための実装(独自認証APIやIDaaSを叩く)をします。

このポイントさえ押さえれば、どんな認証機構を使って、どのような処理を挟んでも問題がない、ある程度自由が許されていることがわかります。

まとめ

elm-spaはSPAを作るため、よく設計された素晴らしいツールです。しかし、仕組みを理解していなければ、想定した挙動をしなかったり、独自の認証の仕組みを作ってしまいます。しっかりとした理解で、同じようなコードになるように気をつけてみましょう。

Discussion