はじめに
この記事はElixirアドベントカレンダー2025シリーズ2の17日目の記事です。
今回はユーザー登録API,登録時のトークンを使用した認証の実装について解説します
ユーザー登録API
スマホアプリなんで、メールアドレスとパスワードを入れてーとかやってると離脱されるので(偏見)
必要になったら後でメールアドレスを登録してもらう感じで、ラフに登録してもらいましょう
ユーザー情報なしなユーザー(以下ゲスト)を作成すregister_guest_userを作ります
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
+ @doc """
+ Register a guest user
+ """
+ def register_guest_user(attrs) do
+ %User{}
+ |> User.guest_changeset(attrs)
+ |> Repo.insert()
+ end
作成時に何かパラメーター入れとかないとボコボコ作られたりするの防ぐためにULIDをパラメータとして受け取ります
[0-9A-HJKMNP-TV-Z]{26}がulidの正規表現になります I,L,O,Uを除いたアルファベットと0-9で構成された26文字の文字列になります
もっと正確にしたい場合は ULID.castを使うとよいです
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
+ def guest_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:id])
+ |> validate_format(:id, ~r/^[0-9A-HJKMNP-TV-Z]{26}$/)
+ end
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
コントローラーは以下のように作成します
前回の記事で作成したエラー時の共通処理にFallbackControllerを使用するようにします
さっき作った関数でユーザーを作成して、成功したら認証トークンとIDを返却します
LiveViewの方で使うセッショントークンを生成する関数があるのでそれを使って、
そのままだとバイナリなのでBase.url_encode64でURLセーフな文字列に変換してもらいます
ログイン後にユーザー情報を返すAPIもいるので作っておきます、userといっしょにinserted_atを渡しているますが、これは生成した認証機能側でトークン有効時間のチェック等で使用しているためそれに合わせてつけています
defmodule BlogWeb.Api.V1.UserController do
use BlogWeb, :controller
alias Blog.Accounts
action_fallback BlogWeb.FallbackController
def register(conn, %{"user" => user_params}) do
case Accounts.register_guest_user(user_params) do
{:ok, user} ->
render(conn, :token, %{id: user.id, token: gen_token(user)})
{:error, changeset} ->
{:error, changeset}
end
end
def status(conn, _params) do
user = conn.assigns.current_scope.user
inserted_at = conn.assigns.token_inserted_at
render(conn, :status, %{user: user, inserted_at: inserted_at})
end
defp gen_token(user) do
user
|> Accounts.generate_user_session_token()
|> Base.url_encode64()
end
end
render関数は以下のようになっているのでtoken関数を作ります
render(conn,[実行する関数名],[引数に渡すMap])
モジュール名はJSONの箇所がJsonだとコントローラー側が認識してくれないので注意が必要です
defmodule BlogWeb.Api.V1.UserJSON do
def token(%{id: id, token: token}) do
%{id: id, token: token}
end
def status(%{user: user, inserted_at: token_inserted_at}) do
%{
id: user.id,
email: user.email,
token_inserted_at: token_inserted_at
}
end
end
トークン認証の実装
ユーザー作成したら認証トークンを返却するようにしたので、そのトークンをauthorizationヘッダーに入ってたら紐づいたユーザーとして認証できるようにしましょう
以下のようなことをしています
- get_req_headerでヘッダーからBearerトークンをバイナリパターンマッチングで取得
- DBにあるトークンはバイナリ型なのでdecode64で文字列からバイナリ型にデコード
- デコードしたトークンで対応するユーザーとログイン日時を取得
- あったら
current_scopeとtoken_inserted_atでアサイン
※どっかでこけたら認証失敗レスポンスを返す
fallback_controllerでも似たようなこと書いてたけど、あちらはコントローラーのアクション内でのエラーにしか対応してない、対してこれはplugといってコントローラーのアクションに入る前に実行される関数なのでこっちでも401レスポンスを返す処理が必要です
@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
+ def require_verify_header(conn, _opts) do
+ with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
+ {:ok, token} <- Base.url_decode64(token),
+ {%Accounts.User{} = user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
+ conn
+ |> assign(:current_scope, Scope.for_user(user))
+ |> assign(:token_inserted_at, token_inserted_at)
+ else
+ _error ->
+ conn
+ |> put_status(:unauthorized)
+ |> put_view(BlogWeb.ErrorJSON)
+ |> render(:"401")
+ |> halt()
+ end
+ end
認証するスコープ、しないスコープの設定
URLをグルーピングする機能としてscopeというものがあります、同じプレフィックスでもURLごとに共通した処理を指定できます、このときに行われる処理1つ1つをplugといい、それをpipe_thuroughでまとめます
スコープ名の後ろのBlogWebのモジュール名の後ろにApi.V1とすることでその配下のモジュール名のプレフィックス部分を省略できます
ユーザー登録を認証なし、ユーザー情報取得、ポストのCRUDを認証ありとしました
- # Other scopes may use custom stacks.
- # scope "/api", BlogWeb do
- # pipe_through :api
- # end
+ scope "/api/v1", BlogWeb.Api.V1 do
+ pipe_through :api
+
+ post "/users/register", UserController, :register
+ end
+
+ scope "/api/v1", BlogWeb.Api.V1 do
+ pipe_through [:api, :require_verify_header]
+
+ get "/users/status", UserController, :status
+ resources "/posts", PostController, except: [:new, :edit]
+ end
動作確認
動作確認の前にプレフィックス周りでapiが重複したので治しておきます
def create(conn, %{"post" => post_params}) do
with {:ok, %Post{} = post} <- Posts.create_post(conn.assigns.current_scope, post_params) do
conn
|> put_status(:created)
- |> put_resp_header("location", ~p"/api/api/v1/posts/#{post}")
+ |> put_resp_header("location", ~p"/api/v1/posts/#{post}")
|> render(:show, post: post)
end
end
APIができたので動作確認を行います
ユーザーの作成にULIDがいるので以下のコマンドでAPIサーバーを起動して
iex -S mix phx.server
以下を実行してIDを作ってください
Ecto.ULID.generate()
ユーザー登録
そのIDをcurlにつけて投げます
curl -X POST http://localhost:4000/api/v1/users/register \
-H "Content-Type: application/json" \
-d '{
"user": {
"id": [さっき作ったID]
}
}'
レスポンスが以下のような感じで返ってきます
{"id":"01KCPFF537ABT3GCXXCYDP8AW7","token":"xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ="}
ログイン情報取得
取得したトークンでidとログイン日時を取得します
curl -X GET http://localhost:4000/api/v1/users/status \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ=" \
-H "Accept: application/json"
{"id":"01KCPFF537ABT3GCXXCYDP8AW7","email":null,"token_inserted_at":"2025-12-19T13:42:28Z"}%
Post一覧
自分のPost一覧を取得します
まだ作ってないので0件ですがちゃんとレスポンスが返ってきました
curl -X GET http://localhost:4000/api/v1/posts \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ=" \
-H "Accept: application/json"
{"data":[]}
トークンがない場合はちゃんと401エラーが返ってきます
curl -X GET http://localhost:4000/api/v1/posts \
-H "Accept: application/json"
{"errors":{"detail":"Unauthorized"}}%
Post作成
curl -X POST http://localhost:4000/api/v1/posts \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ=" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"post": {
"text": "test"
}
}'
{"data":{"id":"01KCPJTGAEY2N0QVW6H2Z6P20T","text":"test"}}
Post更新
curl -X PATCH http://localhost:4000/api/v1/posts/01KCPJTGAEY2N0QVW6H2Z6P20T \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ=" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"post": {
"text": "test2"
}
}'
{"data":{"id":"01KCPJTGAEY2N0QVW6H2Z6P20T","text":"test2"}}%
Post1件取得
curl -X GET http://localhost:4000/api/v1/posts/01KCPJTGAEY2N0QVW6H2Z6P20T \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ=" \
-H "Accept: application/json"
{"data":{"id":"01KCPJTGAEY2N0QVW6H2Z6P20T","text":"test2"}}%
Post削除
curl -X DELETE http://localhost:4000/api/v1/posts/01KCPJTGAEY2N0QVW6H2Z6P20T \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ="
2回目はNot Foundになります
curl -X DELETE http://localhost:4000/api/v1/posts/01KCPJTGAEY2N0QVW6H2Z6P20T \
-H "Authorization: Bearer xbnUvPJj8x2-kNni-0mutfHVFUnEvaMFgjF0nytmfOQ="
# Ecto.NoResultsError at DELETE /api/v1/posts/01KCPK5Q7SV8QNSSV0XMM4VJ1X
Exception:
** (Ecto.NoResultsError) expected at least one result but got none in query:
from p0 in Blog.Posts.Post,
where: p0.id == ^"01KCPK5Q7SV8QNSSV0XMM4VJ1X" and p0.user_id == ^"01KCPFF537ABT3GCXXCYDP8AW7"
ちゃんと動いてますね
最後に
APIサーバーにユーザー登録とトークン認証を実装してPostのCRUDを認証付きですができるようになりました
次はアプリ側を進めたいと思います
本記事は以上になりますありがとうございました