RailsでDiscordログインを実装する方法

## はじめに
この記事では、Rails7のOAuth機能を使ってDiscordログインを実装する方法について書きます。
- Discordログインを実装したい
- omniauthを使いたいけど、使い方がわからない

と言った人はぜひ読んでくださいね。
それでは、順番に解説していきます。

## Discordログインを実装する手順
RailsでDiscordログインを実装する全体像を先に解説します。
1. Discordの開発者用画面で設定キーを取得する
1. `.env`ファイルを設定する
1. `omniauth`というgemを導入する
1. ログインの処理を記述する
1. ビューにリンクを追加する
1. テストコードを書く

以上になります。
それでは、１つずつ解説します！
### 1. Discordの開発者用画面で設定キーを取得する
 [Discord Developer Portal](https://discord.com/developers/applications)にアクセス&ログインしてください
 画像付きで解説しますが、全体としては
 - `CLIENT_ID`
 - `CLIENT_SECRET`

の2つの設定キーを取得して、リダイレクトURLを設定していきます。

[![Image from Gyazo](https://i.gyazo.com/eb5ee1affb47db73ea0dc7aa66bf07c2.png)](https://gyazo.com/eb5ee1affb47db73ea0dc7aa66bf07c2)

[![Image from Gyazo](https://i.gyazo.com/cc34fe4a8088b67eec440427813f5513.png)](https://gyazo.com/cc34fe4a8088b67eec440427813f5513)

[![Image from Gyazo](https://i.gyazo.com/e439af1f21fa20107e598469980ab48f.png)](https://gyazo.com/e439af1f21fa20107e598469980ab48f)

[![Image from Gyazo](https://i.gyazo.com/0ff1addfddddeecbddc4185639dc3051.png)](https://gyazo.com/0ff1addfddddeecbddc4185639dc3051)

[![Image from Gyazo](https://i.gyazo.com/9bff43110e04b08967f41889a3e75b7b.png)](https://gyazo.com/9bff43110e04b08967f41889a3e75b7b)

これでひとまず
- `CLIENT_ID`
- `CLIENT_SECRET`

は取得できました！後程、それらをENVファイルに記述してください。
[![Image from Gyazo](https://i.gyazo.com/c7565f49b98c24121d07ebca94645df9.png)](https://gyazo.com/c7565f49b98c24121d07ebca94645df9)

---
次はリダイレクトURLを設定していきます

リダイレクト先のURLは`/auth/discord/callback`というパスになるようにしてください
例えばローカルなら`http://localhost:3000/auth/discord/callback`になります。

先ほどの`CLIENT_ID`を設定した画面で作業してください
[![Image from Gyazo](https://i.gyazo.com/1348ee2d6ff4a1df5c7c447213a6a9e1.png)](https://gyazo.com/1348ee2d6ff4a1df5c7c447213a6a9e1)

[![Image from Gyazo](https://i.gyazo.com/653ff708cb320ddca1173d4ddf15216d.png)](https://gyazo.com/653ff708cb320ddca1173d4ddf15216d)

以上で設定は完了です。次はその設定を`env`ファイルに書いていきましょう。

### 2. envファイルに設定する
次は、環境変数に設定しましょう。
envファイルでなくても構いませんが、それぞれでカスタマイズお願いします。

`.env`ファイルをプロジェクトルートパス直下(デフォルトの`Gemfile`と同じ位置)に置いてください
`.`をつけることに注意です。

先に`.env`ファイルの中身を書いておきます。
```.env
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
```
上に`CLIENT_ID`, 下に`CLIENT_SECRET`を入れてください。

#### envファイル用追加設定(必要なら)
上記でenvファイル自体は完了になりますが、もしenvファイルを新たに作成した場合は新たに`gem`を追加しましょう。

Gemfileに下記を追加してください
```ruby:Gemfile
gem 'dotenv-rails'
```

コマンドを実行してください
```bash
bundle install
```

必要なら`rails s`などサーバーを再起動してください。これだけでOKです。

### 3. `omniauth`を導入する
`Gemfile`内に下記を追記しましょう。
```ruby: Gemfile
gem 'omniauth-discord'
gem 'omniauth-rails_csrf_protection'
```

コマンド実行です。
```bash
bundle install
```

次に　`config/initializers/omniauth.rb`ファイルを作成して設定を追記します。
```ruby: omniauth.rb
# frozen_string_literal: true

Rails.application.config.middleware.use OmniAuth::Builder do
  # scopeは各自必要な値に修正してください。(ユーザー名とかならそのままでいけます)
  provider :discord, ENV['DISCORD_CLIENT_ID'], ENV['DISCORD_CLIENT_SECRET'], scope: 'identify'

  OmniAuth.config.on_failure = proc { |_env| [302, { 'Location' => '/auth/failure', 'Content-Type' => 'text/html' }, []] }
end
```

ついでに、`config/routes.rb`ファイルに以下を追記してください。
```
# コールバック先(ここにログインの処理を書く)
get "auth/:provider/callback", to: "user_sessions#callback"

# キャンセルなど、失敗した時のルーティング
get 'auth/failure', to: redirect('/')
```

ひとまずこれでOKです。次から、ログイン処理を実装していきましょう。

### 4. ログインの処理を記述する

リダイレクト先であるコントローラーに`callback`アクションを追記しましょう。
僕の場合は`sessions_controller.rb`ファイルになります

下記はコードの意図ですが、理解しておくとよさそうです。
- `request.env['omniauth.auth']` → Discordユーザー名などの情報が入っている
- `reset_session` → セキュリティを考慮して、ログイン前にセッションをリセット
```ruby: users/sessions_controller.rb
# frozen_string_literal: true

class Users::SessionsController < ApplicationController
  def callback
    auth_info = request.env['omniauth.auth']
    if (user = User.find_or_create_from_discord_info(auth_info))
      reset_session
      log_in user
    end

    redirect_to root_path, notice: 'ログインしました'
  end
...
```

Userモデルは下記のようになります。
```ruby: models/user.rb
# frozen_string_literal: true

class User < ApplicationRecord
  def self.find_or_create_from_discord_info(discord_info)
    User.find_or_create_by(uid: discord_info.uid) do |user|
      user.update!(
        uid: discord_info.uid,
        name: discord_info.info.name,
        image: discord_info.info.image
      )
    end
  end
...
```

また、もしログインしないとアクセスできないなど`before_action`している場合は`skip_before_action`してください。

また、ログインのロジックなどについて参考ファイルも載せておきます。
#### ログインなどの参考ファイル
`omniauth`を使っていると独自でログインのロジックを組む必要があるかと思います。
僕はそれを`SessionHelper`モジュールで実装し、`ApplicationController`で読み込んでいます。

`log_in`メソッドを定義しているのは下記のファイルの一部です。
他のメソッドもあえて載せてますので参考になればと思います。
```ruby: helpers/sessions_helper.rb
# frozen_string_literal: true

module SessionsHelper
  def current_user
    return unless (user_id = session[:user_id])

    @current_user ||= User.find_by(id: user_id)
  end

  def logged_in?
    !current_user.nil?
  end

  def current_user?(user)
    user && user == current_user
  end

  def log_in(user)
    session[:user_id] = user.id
  end

  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end
```

```ruby: application_controller.rb
# frozen_string_literal: true

class ApplicationController < ActionController::Base
  include SessionsHelper
  
...
end

```

### 5. ビューにリンクを追加する
次はビューファイルにログインボタンを設定しましょう。
```erb: ログインボタン
<%= button_to '/auth/discord', method: :post, data: { turbo: false } do %>
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-discord" viewBox="0 0 16 16">
    <path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z" />
  </svg>
  Discord アカウントでログイン
<% end %>
```

少し解説します。
#### POST通信について

`omniauth-rails_csrf_protection`というgemの影響でCSRF攻撃を防ぐため、`method: :post`を記述しています。

#### Turboオフについて

ログインボタンでは`turbo`をオフにせよとのことでした。
なので、`data: { turbo: false }`を追記しています

`turbo`ってなんぞや？という人も今はふーんで構いません。

### 6. テストコードを書く
ロジックを書いたら、しっかりテストコードも書きましょう。
フレームワークは`RSpec`を使って書きました。

まずはモデルテストになります。

```ruby: models/user_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe User do
  describe '.find_or_create_from_discord_info' do
    let(:login_user) { User.find_or_create_from_discord_info(auth_info) }
    let(:auth_info) { OmniAuth::AuthHash.new({ provider: 'discord', uid: '123456', info: { name: 'yocchan', image: 'https://discord.cdn.example.com' } }) }

    context '新しく作成されるユーザーの場合' do
      it 'ユーザーが新規作成される' do
        expect(login_user.uid).to eq '123456'
        expect(login_user.name).to eq 'yocchan'
        expect(login_user.image).to eq 'https://discord.cdn.example.com'
      end
    end

    context 'すでに作成されているユーザーの場合' do
      let!(:user) { create(:user, uid: '123456') }

      it 'すでに作成ずみのユーザーが返る' do
        expect(login_user).to eq user
      end
    end
  end

...
```

また、システムテストは下記になります。
```ruby: system/users/sessions_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Users::Sessions' do
  let!(:user) { create(:user) }
  before { OmniAuth.config.test_mode = true }

  describe '#callback' do
    before { OmniAuth.config.add_mock(:discord, { uid: user.uid, info: { name: user.name, image: user.image } }) }

    context '一度ログインしたことあるユーザーの場合' do
      it 'ログインし、タイムライン画面に遷移する' do
        visit new_users_session_path
        click_on 'Discord アカウントでログイン'
        expect(page).to have_css '.text-success', text: 'ログインしました'
      end
    end
  end

  describe '#failure' do
    before { OmniAuth.config.mock_auth[:discord] = :invalid_credentials }

    it 'ログインが失敗する' do
      visit new_users_session_path
      click_on 'Discord アカウントでログイン'
      expect(page).to have_link, 'Discord アカウントでログイン'
    end
  end
end

```

もし、テストが落ちたりしたら、微修正などお願いします。
ざっとこれで実装完了です！お疲れ様でした。

## 参考にさせていただいた記事

https://qiita.com/masayoshi4649/items/46fdb744cb8255f5eb98

https://zenn.dev/choco0809/articles/7e4f696c8c9878

２つの記事がとっても参考になりました。
ありがとうございました！

いいねや、ストックしてくださると嬉しいです。
それでは！