PhoenixにGoogle認証を実装する

今回はPhoenixにGoogle認証を実装します。
SNS認証を扱えるライブラリ**ueberauth**を使用して簡単に実装してみます。

https://hex.pm/packages/ueberauth

## プロジェクトの作成

まずは今回のためにPhoenixプロジェクトを作成します。

```bash
mix phx.new auth_sample --no-dashboard
cd auth_sample
mix ecto.create
mix phx.server
```

![スクリーンショット 2024-03-21 200317.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/c3c668e6-8d23-79a3-af4a-5b8bf2959a8a.png)

## mix phx.gen.auth コマンドで認証機能を追加

以下のコマンドで認証機能を追加します。

```bash
mix phx.gen.auth Accounts User users
mix deps.get
mix ecto.migrate
```

`mix phx.gen.auth`コマンドを使用すると途中「LiveViewを使用するか？」と入力が求められます。今回は`y`を入力します。


## Googleでログインボタンを追加

![スクリーンショット 2024-03-21 202051.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/39162727-ca65-4655-50ca-88e469340925.png)


Google認証用のボタンを作成します。

https://developers.google.com/identity/branding-guidelines?hl=ja

ガイドラインにあるアイコンをダウンロードして使用します。

https://developers.google.com/static/identity/images/signin-assets.zip?hl=ja

適当にアイコンを選んで`priv/static/images/google_icon.png`としてコピーしておきます。

```diff_elixir:lib/auth_sample_web/live/user_login_live.ex
defmodule AuthSampleWeb.UserLoginLive do
  use AuthSampleWeb, :live_view

  def render(assigns) do
    ~H"""
...
      </.simple_form>

+      <div class="py-6">
+        <.link href={~p"/auth/google"} class="w-full">
+          <.button class="w-full bg-gray-100 bg-no-repeat bg-contain bg-[url('/images/google_icon.png')] border-2 border-black hover:bg-gray-200 hover:opacity-75">
+            <span class="text-black">Sing in with Google</span>
+          </.button>
+        </.link>
+      </div>
    </div>
    """
  end
...
end
```

これでボタンができました。

![スクリーンショット 2024-03-21 204105.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/9cfb2f16-9ec7-34f0-1a8c-9522a37d5a97.png)

## GCPでOAuth2.0の準備

Googleで認証ができるようにGCPを使って準備していきます。

https://console.cloud.google.com/

### プロジェクトの作成

コンソール画面に入れたら下の画像の通りに進手めてください。

![スクリーンショット 2024-03-21 204707.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/d4ad9ef0-4876-79b2-5346-bde94bf591de.png)

![スクリーンショット 2024-03-21 204716.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/434b4620-813a-f536-b6fe-98a3f756e470.png)


![スクリーンショット 2024-03-21 204741.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/5582f93c-45a6-0265-a77a-873052b9bd17.png)

### 認証情報の作成

![スクリーンショット 2024-03-21 204821.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/e5ec6767-6ad3-9fae-4123-acaa20d1a569.png)

![スクリーンショット 2024-03-21 204835.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/939e7512-8b92-6ecf-4ae8-ea3e4f54a555.png)

![スクリーンショット 2024-03-21 204850.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/5d6bc96e-3245-2d45-be2d-0ef7dace2ce2.png)

![スクリーンショット 2024-03-21 204901.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/089c330a-efb1-7ae7-e3b7-18dee29557bf.png)

![スクリーンショット 2024-03-21 204930.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/e788b458-40a5-dc14-1b4a-451b1b164766.png)

![スクリーンショット 2024-03-21 205032.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/35f529f0-a930-e3c3-f583-d10d5c0a426e.png)

上の画像の続き

![スクリーンショット 2024-03-21 205037.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/413c443e-8fd1-cd9b-2540-a039127f8f1e.png)

:::note warn
この後も入力項目がありますが、入力必須の項目だけ入力します。
:::

### OAuthクライアントIDの作成

![スクリーンショット 2024-03-21 205238.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/bb19700e-6b58-4e64-8434-1c28041a7fa7.png)

![スクリーンショット 2024-03-21 205248.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/50239e7d-4a64-9b09-771f-898546ca7c9a.png)

![スクリーンショット 2024-03-21 205355.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/00059599-52a8-9a29-5164-21739ef64fbb.png)

:::note info
アプリケーションの種類は「ウェブアプリケーション」を選択してください。

承認済みのリダイレクトURLの「URLを追加」をクリックして`http://localhost:4000/auth/google/callback`を追加します。
本番環境で使用する場合は`https://ドメイン/auth/google/callback`を追加します。
:::

![スクリーンショット 2024-03-21 205430.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/3da69f0e-788d-3be0-d118-f635980a8a79.png)


発行された「クライアントID」と「クライアントシークレット」を.envファイルを作成して環境変数で登録できるようにしておきます。
2つの値はメモしておきます。

「クライアントID」と「クライアントシークレット」を簡単に環境変数に登録できるように`.env`ファイルにしておきます。

```bash:.env
export GOOGLE_CLIENT_ID=クライアントID
export GOOGLE_CLIENT_SECRET=クライアントシークレット
```

`.gitignore`ファイルで`.env`が選択されないようにしておきます。

```diff:.gitignore
...

# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/

+ .env
```

以下のコマンドを実行して環境変数を登録します。

```bash
source .env
```

## Google認証を実装

GCPでの設定が終わったのでPHoenixプロジェクトにGoogle認証機能を追加していきます。

https://github.com/ueberauth/ueberauth_example

サンプルが用意されているのでこちらを参考にします。

### ueberauth_google パッケージを追加

Google認証を使用するためのパッケージを追加します。

https://hex.pm/packages/ueberauth_google

```diff_elixir:mix.exs
defmodule AuthSample.MixProject do
...
  defp deps do
    [
      ...
      {:plug_cowboy, "~> 2.5"},
+      {:ueberauth_google, "~> 0.12.1"}
    ]
  end
...
end

```

パッケージを以下のコマンドで取得します。

```bash
mix deps.get
```

### uebrauthのconfig設定

uebrauth用のコンフィグを追加します。

```diff_elixir:config/config.exs
...

+ config :ueberauth, Ueberauth,
+   providers: [
+     google: {Ueberauth.Strategy.Google, []}
+   ]

+ config :ueberauth, Ueberauth.Strategy.Google.OAuth,
+   client_id: {System, :get_env, ["GOOGLE_CLIENT_ID"]},
+   client_secret: {System, :get_env, ["GOOGLE_CLIENT_SECRET"]}

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
```

### ルーティングの設定

Google認証用のルートを追加します。

```diff_elixir:lib/auth_sample_web/router.ex
defmodule AuthSampleWeb.Router do
...
+   scope "/auth", AuthSampleWeb do
+     pipe_through :browser
+
+     get "/:provider", AuthController, :request
+     get "/:provider/callback", AuthController, :callback
+     post "/:provider/callback", AuthController, :callback
+   end
 ...
end
```

### コントローラーの実装

`AuthController`のアクションを作成していきます。

```elixir:lib/auth_sample_web/controllers/auth_controller.ex
defmodule AuthSampleWeb.AuthController do
  use AuthSampleWeb, :controller
  plug Ueberauth

  def request(conn, _params), do: nil

  def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
    conn
    |> put_flash(:error, "Failed to authenticate.")
    |> redirect(to: ~p"/users/log_in")
  end

  def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
    IO.inspect(auth, label: "response auth")
    
    conn
    |> put_flash(:info, "Successfully authenticated.")
    |> put_session(:current_user, auth.uid)
    |> configure_session(renew: true)
    |> redirect(to: ~p"/")
  end
end
```

デバッグように`IO.inspect(auth, label: "response auth")`を追加しています。

サーバーを立ち上げて`http://localhost:4000/users/log_in`にアクセスしてGoogle認証ボタンを押してみましょう。
[http://localhost:4000/users/log_in](http://localhost:4000/users/log_in)

```bash
mix phx.server
````

![スクリーンショット 2024-03-21 223126.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/e159335f-7746-fe8f-bfac-677b4b637812.png)

![スクリーンショット 2024-03-21 224359.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/1dc0a3d3-3264-b5d1-9c57-b212a8496e95.png)

![スクリーンショット 2024-03-21 224436.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/d38fbbd9-5a8e-91fd-0d35-4cc30715fdca.png)


![スクリーンショット 2024-03-21 224443.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/9dd86c07-219b-90e6-488a-3f992c600c75.png)

`IO.inspect/2`の出力は以下

```elixir
response auth: %Ueberauth.Auth{
  uid: "googleアカウントのuid",
  provider: :google,
  strategy: Ueberauth.Strategy.Google,
  info: %Ueberauth.Auth.Info{
    name: nil,
    first_name: nil,
    last_name: nil,
    nickname: nil,
    email: "googleアカウントのメールアドレス",
    location: nil,
    description: nil,
    image: "画像のurl",
    phone: nil,
    birthday: nil,
    urls: %{profile: nil, website: nil}
  },
  credentials: %Ueberauth.Auth.Credentials{
    token: "****",
    refresh_token: nil,
    token_type: "Bearer",
    secret: nil,
    expires: true,
    expires_at: 1711032276,
    scopes: ["openid", "https://www.googleapis.com/auth/userinfo.email"],
    other: %{}
  },
  extra: %Ueberauth.Auth.Extra{
    raw_info: %{
      user: %{
        "email" => "googleアカウントのメールアドレス",
        "email_verified" => true,
        "picture" => "画像のurl",
        "sub" => "*****"
      },
      token: %OAuth2.AccessToken{
        access_token: "*****",
        refresh_token: nil,
        expires_at: 1711032276,
        token_type: "Bearer",
        other_params: %{
          "id_token" => "******",
          "scope" => "openid https://www.googleapis.com/auth/userinfo.email"
        }
      }
    }
  }
}
```

### sns_authsテーブルの作成

Google認証で受け取った値を活用してユーザー登録をするために`sns_auths`テーブルを作成します。

```bash
mix phx.gen.schema Accounts.SnsAuth sns_auths provider:string uid:string user_id:references:users
```

作成されたマイグレーションファイルを一部変更します。

```diff_elixir:priv/repo/migrations/タイムスタンプ_create_sns_auths.exs
defmodule AuthSample.Repo.Migrations.CreateSnsAuths do
  use Ecto.Migration

  def change do
    create table(:sns_auths) do
-     add :provider, :string
-     add :uid, :string
-     add :user_id, references(:users, on_delete: :nothing)
+     add :provider, :string, null: false
+     add :uid, :string, null: false
+     add :user_id, references(:users, on_delete: :delete_all), null: false

      timestamps(type: :utc_datetime)
    end

    create index(:sns_auths, [:user_id])
  end
end
```

同じくスキーマファイルも変更します。

```diff_elixir:lib\auth_sample\accounts\sns_auth.ex
defmodule AuthSample.Accounts.SnsAuth do
  use Ecto.Schema
  import Ecto.Changeset
+ alias AuthSample.Accounts.User

  schema "sns_auths" do
    field :provider, :string
    field :uid, :string
-    field :user_id, :id
+    belongs_to :user, User

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(sns_auth, attrs) do
    sns_auth
    |> cast(attrs, [:provider, :uid])
    |> validate_required([:provider, :uid])
  end
end
```

`AuthSample.Accounts.User`モジュールのスキーマに`sns_auths`テーブルのスキーマを関連付けさせます。

今回、SNS認証の中ではGoogle認証しか使用していませんが、ほかのSNS認証を追加する際に拡張性がいいように`has_many`で1対多の関係で関連付けさせます。

```diff_elixir:lib/auth_sample/accounts/user.ex
defmodule AuthSample.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
+ alias AuthSample.Accounts.SnsAuth

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime
+   has_many :sns_auths, SnsAuth

    timestamps(type: :utc_datetime)
  end
...
end
```

また、Google認証でアカウントを登録する場合はパスワードが必要ありません。そのため、`users`テーブルのマイグレーションファイルを一部修正します。

```diff_elixir:defmodule AuthSample.Repo.Migrations.CreateUsersAuthTables do
  use Ecto.Migration

  def change do
    execute "CREATE EXTENSION IF NOT EXISTS citext", ""

    create table(:users) do
      add :email, :citext, null: false
-      add :hashed_password, :string, null: false
+      add :hashed_password, :string
      add :confirmed_at, :naive_datetime
      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])

    create table(:users_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false
      add :context, :string, null: false
      add :sent_to, :string
      timestamps(updated_at: false)
    end

    create index(:users_tokens, [:user_id])
    create unique_index(:users_tokens, [:context, :token])
  end
end
```

以下のコマンドでdbをリセットします。

```bash
mix ecto.reset
```

### アカウントの登録～ログインまでの機能を実装

まず、Google認証でアカウントを作成しているかをチェックするために`uid`の値で`sns_auths`テーブルからデータを取得する関数を作成します。

```diff_elixir:lib/auth_sample/accounts.ex
defmodule AuthSample.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias AuthSample.Repo

-  alias AuthSample.Accounts.{User, UserToken, UserNotifier}
+  alias AuthSample.Accounts.{User, UserToken, UserNotifier, SnsAuth}

  ## Database getters

...
  @doc """
  Gets a single user.

  Raises `Ecto.NoResultsError` if the User does not exist.

  ## Examples

      iex> get_user!(123)
      %User{}

      iex> get_user!(456)
      ** (Ecto.NoResultsError)

  """
  def get_user!(id), do: Repo.get!(User, id)

+ def get_sns_auth(uid), do: Repo.get_by(SnsAuth, uid: uid)
...
end
```

`sns_auths`テーブルにデータを登録する関数を作成します。

```diff_elixir:lib/auth_sample/accounts.ex
defmodule AuthSample.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias AuthSample.Repo

  alias AuthSample.Accounts.{User, UserToken, UserNotifier, SnsAuth}
+ alias Ueberauth.Auth
...
  ## User registration

  @doc """
  Registers a user.

  ## Examples

      iex> register_user(%{field: value})
      {:ok, %User{}}

      iex> register_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
+  def register_user(%Auth{} = auth) do
+    now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+    Ecto.Multi.new()
+    |> Ecto.Multi.insert(:user, %User{email: auth.info.email, confirmed_at: now})
+    |> Ecto.Multi.insert(:sns_auth, fn %{user: %User{id: user_id}} ->
+      %SnsAuth{provider: auth.provider, uid: auth.uid, user_id: user_id}
+    end)
+    |> Repo.transaction()
+  end

  def register_user(attrs) do
    %User{}
    |> User.registration_changeset(attrs)
    |> Repo.insert()
  end
...
+  ## SNSAuth
+
+  def create_sns_auth(%User{id: user_id}, %Auth{} = auth) do
+    Repo.insert(%SnsAuth{provider: auth.provider, uid: auth.uid, user_id: user_id})
+  end
end
```

とりあえず、いろいろ考慮するべきことがありますが、必要な機能はそろいました。

続いて`AuthController`を修正していきます。

```diff_elixir:lib/auth_sample_web/controllers/auth_controller.ex
defmodule AuthSampleWeb.AuthController do
  use AuthSampleWeb, :controller
  plug Ueberauth
+  alias AuthSample.Accounts
+  alias AuthSample.Accounts.{User, SnsAuth}
+  alias AuthSampleWeb.UserAuth

  def request(conn, _params), do: nil

  def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
    conn
    |> put_flash(:error, "Failed to authenticate.")
    |> redirect(to: ~p"/users/log_in")
  end

  def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
-    IO.inspect(auth, label: "response auth")
-
-    conn
-    |> put_flash(:info, "Successfully authenticated.")
-    |> put_session(:current_user, auth.uid)
-    |> configure_session(renew: true)
-    |> redirect(to: ~p"/")
+   user = Accounts.get_user_by_email(auth.info.email)
+    sns_auth = Accounts.get_sns_auth(auth.uid)
+    log_in_user(conn, user, sns_auth, auth)
  end
+
+  defp log_in_user(conn, %User{} = user, %SnsAuth{}, _auth) do
+    UserAuth.log_in_user(conn, user, %{"remember_me" => "true"})
+  end
+
+  defp log_in_user(conn, %User{} = user, nil, auth) do
+    case Accounts.create_sns_auth(user, auth) do
+      {:ok, %SnsAuth{} = sns_auth} ->
+        log_in_user(conn, user, sns_auth, auth)
+
+      {:error, _cs} ->
+        conn
+        |> put_flash(:error, "Failed to authenticate.")
+        |> redirect(to: ~p"/users/log_in")
+    end
+  end
+
+  defp log_in_user(conn, _user, _sns_auth, auth) do
+    case Accounts.register_user(auth) do
+      {:ok, %{user: user, sns_auth: sns_auth}} ->
+        log_in_user(conn, user, sns_auth, auth)
+
+      {:error, _, _cs, _} ->
+        conn
+        |> put_flash(:error, "Failed to authenticate.")
+        |> redirect(to: ~p"/users/log_in")
+    end
+  end
end
```

`http://localhost:4000/users/log_in`にアクセスして実際にログインできるか確認しましょう。
[http://locahost:4000/users/log_in](http://localhost:4000/users/log_in)

```bash
mix phx.server
```

![スクリーンショット 2024-03-22 005815.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/e945c89c-3cf8-fe2f-32c7-4ae3d0bdca8b.png)

認証で使用するgoogleアカウントを選択したらログインできるようになりました。

![スクリーンショット 2024-03-22 010334.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/b3b5a03d-48b2-a838-528a-0b18a74dc5b2.png)

psqlでデータを確認してらちゃんとデータが入っています。
![スクリーンショット 2024-03-22 010953.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646507/bd8e44be-46eb-ad12-b68e-617e3b62bb6c.png)

ログアウトしてからもう一度Googleでログインしても問題なく機能しています。

## 最後

`ueberauth`を使うことで簡単にSNS認証することができました。
他にもSNS認証があるみたいなので拡張性がすごくあるモジュールです。

ここに乗せたコードはとりあえず動けばいいの精神で書いたので、もし参考にする方はコードをいろいろと変えてみてください。

ここまで読んでいただきありがとうございました。:thumbsup: