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サーバー ユーザー登録、トークン認証

Last updated at Posted at 2025-12-17

はじめに

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

今回はユーザー登録API,登録時のトークンを使用した認証の実装について解説します

ユーザー登録API

スマホアプリなんで、メールアドレスとパスワードを入れてーとかやってると離脱されるので(偏見)
必要になったら後でメールアドレスを登録してもらう感じで、ラフに登録してもらいましょう

ユーザー情報なしなユーザー(以下ゲスト)を作成すregister_guest_userを作ります

lib/blog/accounts.ex:L76
  @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を使うとよいです

lib/blog/accounts/user.ex:L18
  @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を渡しているますが、これは生成した認証機能側でトークン有効時間のチェック等で使用しているためそれに合わせてつけています

lib/blog_web/controllers/api/v1/user_controller.ex
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だとコントローラー側が認識してくれないので注意が必要です

lib/blog_web/controllers/api/v1/user_json.ex
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ヘッダーに入ってたら紐づいたユーザーとして認証できるようにしましょう

以下のようなことをしています

  1. get_req_headerでヘッダーからBearerトークンをバイナリパターンマッチングで取得
  2. DBにあるトークンはバイナリ型なのでdecode64で文字列からバイナリ型にデコード
  3. デコードしたトークンで対応するユーザーとログイン日時を取得
  4. あったらcurrent_scopetoken_inserted_atでアサイン
    ※どっかでこけたら認証失敗レスポンスを返す

fallback_controllerでも似たようなこと書いてたけど、あちらはコントローラーのアクション内でのエラーにしか対応してない、対してこれはplugといってコントローラーのアクションに入る前に実行される関数なのでこっちでも401レスポンスを返す処理が必要です

lib/blog_web/user_auth.ex:L270
  @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を認証ありとしました

lib/blog_web/router.ex
-  # 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が重複したので治しておきます

lib/blog_web/controllers/api/v1/post_controller.ex
  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を認証付きですができるようになりました
次はアプリ側を進めたいと思います

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

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?