elm-spa v6でユーザ認証実装
ElmでSPAをするためのボイラプレートを生成してくれるelm-spaのバージョン6がリリースされてから約一年が経ちました。バージョン5を使い続けていたのですが、そろそろ使おうと思いたち、その中でも目玉(?)機能のユーザ認証機能が少し難しく感じたため、解説を書こうと思います。
2つのサンプルプログラムを通してユーザ認証機能について、解説をしようと思います。ElmでSPAをしたり、elm-spaは抽象化されている部分もあるため、中級者・上級者向けの解説となります。
1つ目は、乱数で認証が1/2で成功するユーザ認証のサンプルです。
2つ目は、elm-spa公式のフォーム入力をすることで認証できるユーザ認証のサンプルです。
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-spa
のdefaults
, 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.elm
のbeforeProtectedInit
関数を見てみると、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_.elm
のpage
関数を見ると、先程のサンプル同じく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.elm
のbeforeProtectedInit
関数を見てみると、Shared.Model
のstorage.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