はじめに
この記事はElixirアドベントカレンダー2025シリーズ2の20日目の記事です。
ログインが必要なパスに対して実行される関数
今回はセッション管理を実装します
セッション周りをチェックする関数は主に以下の3つになります
- fetch_current_scope_for_user
- require_authenticated_user
- require_authenticated
どこでどのように実行されているのかを見てみましょう
pipe_throughはリクエストがあった場合最初に、ここにあるplug形式の関数またはplugをまとめたpipelineを実行します
最初はbrowserでこれは何かというと
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を実行します
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でアサインという処理をしています
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がなかったらログイン画面に戻すという記述をしています
@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がなかったらログインにリダイレクトします
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を使用してますね
- sessionからuser_tokenで取得を試みる
- remeber_me_cookieから取得を試みる
- 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の箇所も消しておきましょう
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
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側も追加します
関数はすでにあるので、適切なパラメーターを渡すだけです
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
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リクエストにして実際に生成できるようにする
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情報で新しくトークンを作ります
def refresh_token(conn, _) do
user = conn.assigns.current_scope.user
render(conn, :token, %{id: user.id, token: gen_token(user)})
end
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は使わないので消す
クッキーの設定周りを消します
# 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"
]
クッキーにトークンを書き込む箇所を消します
- 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
上記の関数なのでごっそり消します
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
ログインの有無でのリダイレクト設定
起動時に開かれるページをデフォルトページからログインの有無でリダイレクト先を変えるようにします
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
動作確認
ログイン状態でアプリを再起動してもちゃんと維持されています
ログアウト後再起動しても、ちゃんとログアウト状態が維持されてるのを確認できました
最後に
本記事ではセッション周りの動きとコードの解説、セッションの維持、トークン生成、ログアウト、起動時リダイレクトの実装を行いました
次は記事作成機能の実装になります
本記事は以上なりますありがとうございました

