9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirDesktopで作るブログアプリ API&モバイル セッション周りの解説とセッション管理実装

9
Posted at

はじめに

この記事はElixirアドベントカレンダー2025シリーズ2の20日目の記事です。

ログインが必要なパスに対して実行される関数

今回はセッション管理を実装します
セッション周りをチェックする関数は主に以下の3つになります

  • fetch_current_scope_for_user
  • require_authenticated_user
  • require_authenticated

どこでどのように実行されているのかを見てみましょう

pipe_throughはリクエストがあった場合最初に、ここにあるplug形式の関数またはplugをまとめたpipelineを実行します

最初はbrowserでこれは何かというと

lib/blog_app_web/router.ex
  scope "/", BlogAppWeb do
->   pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{BlogAppWeb.UserAuth, :require_authenticated}] do
      live "/users/settings", UserLive.Settings, :edit
      live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
      live "/welcome", UserLive.Welcome
    end

    post "/users/update-password", UserSessionController, :update_password
  end

routerの上の方に定義されているpipelineですね
ここでfetch_current_scope_for_userを実行します

lib/blog_app_web/router.ex
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {BlogAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
->  plug :fetch_current_scope_for_user
  end

これがどこにあるかというとuser_auth.exにあります
connとopstionsを受け取ってconnを返すのがplug形式の関数となります
認証トークンを取得してユーザーのログイン情報を取得しスコープとしてアサイン、できなかったらnilでアサインという処理をしています

lib/blog_app_web/user_auth.ex:L67
  def fetch_current_scope_for_user(conn, _opts) do
    with {token, conn} <- ensure_user_token(conn),
         {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
      conn
      |> assign(:current_scope, Scope.for_user(user))
      |> maybe_reissue_user_session_token(user, token_inserted_at)
    else
      nil -> assign(conn, :current_scope, Scope.for_user(nil))
    end
  end

pipe_throughの2つめrequire_authenticated_userはplugでこれもuser_authにあります
これはcurrent_socpeとuserがなかったらログイン画面に戻すという記述をしています

lib/blog_app_web/user_auth.ex:L268
  @doc """
  Plug for routes that require the user to be authenticated.
  """
  def require_authenticated_user(conn, _opts) do
    if conn.assigns.current_scope && conn.assigns.current_scope.user do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

最後が:require_authenticatedですね

これはLiveView専用でmountされるときに実行されます

指定方法はlive_sessioonで一意な名前をつけてmount時に実行する関数をon_mountでまとめます

live_session :require_authenticated_user,
      on_mount: [{BlogAppWeb.UserAuth, :require_authenticated}]

on_mountはリストなので複数設定でき、{モジュール名、on_mountハンドリング名}の形で指定します

on_mount:
  [
    {BlogAppWeb.UserAuth, :require_authenticated}
    {BlogAppWeb.OtherModule, :other_on_mount_function}
  ]

内容はrequire_authenticated_userとほぼ同じでcurrent_scopeがなかったらログインにリダイレクトします

lib/blog_app_web/user_auth.ex:L:219
  def on_mount(:require_authenticated, _params, session, socket) do
    socket = mount_current_scope(socket, session)

    if socket.assigns.current_scope && socket.assigns.current_scope.user do
      {:cont, socket}
    else
      socket =
        socket
        |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
        |> Phoenix.LiveView.redirect(to: ~p"/users/log-in")

      {:halt, socket}
    end
  end

各関数にIO.inspectをいれてログインが必要なページWelcomeを開くと以下の順番で実行されています

:browser_plug_fetch_current_scope_for_user
:plug_require_authenticated_user
:on_mount_require_authenticated

LiveViewのページにアクセスしようといて不正な認証トークンだった場合でも2つ目で弾いて終わりではなく以下の3つ全部が実行されています

:browser_plug_fetch_current_scope_for_user
:plug_require_authenticated_user
:on_mount_require_authenticated

contollerのページだったときにログインにリダイレクトさせるのは
plugのrequire_authenticated_user
liveviewのページだったときにログインにリダイレクトさせるのは
on_mountのrequire_authenticated
という形になります

なのでどの文脈でチェックさせてリダイレクトさせるのかを注意して設計しましょう

fetch_current_scope_for_userの流れ

認証トークンからユーザー情報を取得するのがfetch_current_scope_for_userというのがわかったのでこちらを詳しく見ていきましょう

  def fetch_current_scope_for_user(conn, _opts) do
    with {token, conn} <- ensure_user_token(conn),
         {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
      conn
      |> assign(:current_scope, Scope.for_user(user))
      |> maybe_reissue_user_session_token(user, token_inserted_at)
    else
      nil -> assign(conn, :current_scope, Scope.for_user(nil))
    end
  end

まずトークンを取得するのにensure_user_tokenを使用してますね

  1. sessionからuser_tokenで取得を試みる
  2. remeber_me_cookieから取得を試みる
  3. 1,2がだめだったらnilを返す
  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
      conn = fetch_cookies(conn, signed: [@remember_me_cookie])

      if token = conn.cookies[@remember_me_cookie] do
        {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
      else
        nil
      end
    end
  end

トークンがあり、ユーザーも正常に取得できた場合に以下の関数が実行されます
ユーザー取得じに返却されるtoken_inseted_atを使って7日以上前に作られたトークンかをチェック(@session_reissue_age_in_daysの初期値は7日)
古かったら create_or_extend_sessionを実行します

  defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
    token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)

    if token_age >= @session_reissue_age_in_days do
      create_or_extend_session(conn, user, %{})
    else
      conn
    end
  end

create_or_extend_sessionですが、新規にトークンを作成して、新しくセッションを張ってそのセッションにトークンを保存します

  defp create_or_extend_session(conn, user, params) do
    token = Accounts.generate_user_session_token(user)
    remember_me = get_session(conn, :user_remember_me)

    conn
    |> renew_session(user)
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params, remember_me)
  end

これ移行はセッション管理あまり関係ないので終わりにして次は修正を入れていきます

fetch_current_scope_for_userの改修

そもそもどのようにあってほしいかというと

  • アプリを再起動するとセッションがリセットされるので保持したい
    • 認証トークンは端末に保存しているのでそれを使いたい
  • ログアウト時にはそのトークンを消してほしい
  • トークンの生成関数の中身をAPIリクエストにして実際に生成できるようにする
  • remember_meは使わないので消す
  • トップページ開いたときにログインの有無でリダイレクトを変えてほしい

ができればあとはuser_authでうまく吸収してくれます

アプリ再起動時のセッション復帰(ensure_user_token)

これを実装していきます

  • アプリを再起動するとセッションがリセットされるので保持したい
    • 認証トークンは端末に保存しているのでそれを使いたい

手を加えるのはensure_tokenで
トークンがセッションになかったらトークンファイルを見に行ってそれでもなかったらnilを返します

  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
-     conn = fetch_cookies(conn, signed: [@remember_me_cookie])
-
-     if token = conn.cookies[@remember_me_cookie] do
-       {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
-     else
-       nil
-     end
+     case read_token() do
+       nil ->
+         nil
+
+       token ->
+         {token, put_token_in_session(conn, token)}
+     end      
    end
  end

+ defp read_token() do
+   case File.read(BlogApp.token_path()) do
+     {:ok, token} ->
+       Base.url_decode64!(token)
+
+     {:error, _} ->
+       nil
+   end
+  end

ログアウト時のトークン削除(delete_user_session_token)

こちらを実装していきます

  • ログアウト時にはそのトークンを消してほしい

認証トークンを削除するdelete_user_session_tokenをAPIを叩いて、成功したら端末のトークンを削除するようにします
トークンは取得した時はバイナリなので、APIに渡せるようにエンコードします

またついでに使わないremember_newの箇所も消しておきましょう

lib/blog_web/user_auth.ex:L48
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
-  user_token && Accounts.delete_user_session_token(user_token)   
+  user_token && Accounts.delete_user_session_token(Base.url_encode64(user_token))

    if live_socket_id = get_session(conn, :live_socket_id) do
      BlogAppWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session(nil)
-   |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: ~p"/")
  end
lib/blog_app/accounts.ex
  def delete_user_session_token(token) do
    {:ok, resp} = Req.post(Api.client(), url: "/logout", json: %{token: token})

    case resp.status do
      200 ->
        File.rm(BlogApp.token_path())
        :ok

      _ ->
        :error
    end
  end

API側も追加します

関数はすでにあるので、適切なパラメーターを渡すだけです

lib/blog_web/controllers/api/v1/user_controller.ex
  def logout(conn, %{"token" => token}) do
    token = Base.url_decode64!(token)

    case Accounts.delete_user_session_token(token) do
      :ok -> send_resp(conn, 200, "OK")
      _ -> send_resp(conn, 400, "Bad Request")
    end
  end
lib/blog_web/router.ex
  scope "/api/v1", BlogWeb.Api.V1 do
    pipe_through [:api, :require_verify_header]

    get "/users/status", UserController, :status
+   post "/logout", UserController, :logout

    resources "/posts", PostController, except: [:new, :edit]
  end

トークンの生成関数の実装(generate_user_session_token)

こちらを実装します

  • トークンの生成関数の中身をAPIリクエストにして実際に生成できるようにする
lib/blog_app/accounts.ex
  def generate_user_session_token(_) do
    {:ok, resp} =
      Req.post(Api.client(), url: "/refresh_token")

    case resp.status do
      200 ->
        File.write!(BlogApp.token_path(), resp.body["token"])

        resp.body["token"]
      _ ->
        nil
    end
  end

API側も実装します

アサインされたuser情報で新しくトークンを作ります

lib/blog_web/controllers/api/v1/user_controller.ex
  def refresh_token(conn, _) do
    user = conn.assigns.current_scope.user
    render(conn, :token, %{id: user.id, token: gen_token(user)})
  end
lib/blog_web/router.ex
  scope "/api/v1", BlogWeb.Api.V1 do
    pipe_through [:api, :require_verify_header]

    get "/users/status", UserController, :status
+   post "/refresh_token", UserController, :refresh_token
    post "/logout", UserController, :logout

    resources "/posts", PostController, except: [:new, :edit]
  end

不要コードの削除

以下を消してきます

  • remember_meは使わないので消す

クッキーの設定周りを消します

lib/blog_web/user_auth.ex:L10
 # Make the remember me cookie valid for 14 days. This should match
 # the session validity setting in UserToken.
 @max_cookie_age_in_days 14
 @remember_me_cookie "_blog_web_user_remember_me"
 @remember_me_options [
  sign: true,
   max_age: @max_cookie_age_in_days * 24 * 60 * 60,
   same_site: "Lax"
 ]

クッキーにトークンを書き込む箇所を消します

lib/blog_web/user_auth.ex:L110
- defp create_or_extend_session(conn, user, params) do
+ defp create_or_extend_session(conn, user, _params) do
    token = Accounts.generate_user_session_token(user)
-   remember_me = get_session(conn, :user_remember_me)

    conn
    |> renew_session(user)
    |> put_token_in_session(token)
-   |> maybe_write_remember_me_cookie(token, params, remember_me)
  end

上記の関数なのでごっそり消します

lib/blog_web/user_auth.ex:L148
  defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, token, _params, true),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn

  defp write_remember_me_cookie(conn, token) do
    conn
    |> put_session(:user_remember_me, true)
    |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
  end

ログインの有無でのリダイレクト設定

起動時に開かれるページをデフォルトページからログインの有無でリダイレクト先を変えるようにします

lib/blog_app_web/controllers/page_controller.ex
defmodule BlogAppWeb.PageController do
  use BlogAppWeb, :controller

  alias BlogApp.Accounts

  def home(conn, _) do
    redirect_to(conn, conn.assigns.current_scope)
  end

  def redirect_to(conn, %Accounts.Scope{}) do
    redirect(conn, to: ~p"/welcome")
  end

  def redirect_to(conn, nil) do
    redirect(conn, to: ~p"/users/register")
  end
end

動作確認

2fe8470a70c24d85d335fc7225dc606b.gif

ログイン状態でアプリを再起動してもちゃんと維持されています

2fe8470a70c24d85d335fc7225dc606b.gif

ログアウト後再起動しても、ちゃんとログアウト状態が維持されてるのを確認できました

最後に

本記事ではセッション周りの動きとコードの解説、セッションの維持、トークン生成、ログアウト、起動時リダイレクトの実装を行いました

次は記事作成機能の実装になります

本記事は以上なりますありがとうございました

9
0
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
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?