1
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?

Rails 7 + Devise で LINEログインを実装する(自前OmniAuth Strategy + メアド補完フロー)

1
Posted at

Rails 7 + Devise の構成で LINEログイン を実装した際の知見をまとめます。

実装にあたって、定番gem omniauth-line を使わず 自前のOmniAuth Strategy
を書きました。理由は後述しますが、結果的に100行未満で書けて、ロジックが分かるので、保守性も上がったかなと思うので結果的に良かったかなと思っています。

また、LINE特有の 「emailスコープを許可しても email が取れないケース」 に対応するため、補完フローも実装しました。

LINEのログイン機能を実装するには、LINE Developersコンソール
にて、開発者登録及びチャンネル作成をしなければならないので、先にそちらの登録・作成をお願いします。

WebアプリにLINEログインを組み込む手順は、こちらに詳しく書いてありますのでご確認お願いします。

採用技術

  • Ruby 3.3 / Rails 7.2
  • Devise + omniauth-oauth2
  • PostgreSQL

なぜ omniauth-line gemを採用しなかったか

選定時に検討しましたが、以下の理由で見送りました。

  1. 最終更新が5年以上前で実質メンテ停止
  2. email スコープ未対応

全体構成

[ユーザー]
    ↓ LINEログインボタン押下
[LINE認可画面]
    ↓ 承認後 callback
[OmniauthCallbacksController#line]
    ├─ email 取れた → User作成 → ログイン完了
    └─ email 取れない → セッションに退避 → メアド補完画面へ
                              ↓
                       [メアド入力フォーム]
                              ↓
                       [#line_complete] → User作成 → ログイン完了

1. 自前OmniAuth Strategy

lib/omniauth/strategies/line.rb に実装します。

なぜ lib/omniauth/strategies/ 配下に置くのか

このディレクトリ・名前空間にはいくつか必然性があります。

  1. OmniAuthの規約: Deviseの config.omniauth :lineOmniAuth::Strategies::Line
    を自動解決するため、この名前空間は必須
  2. Rackミドルウェア層の住人: Strategyはコントローラに到達するに動く。token取得やid_token検証はコントローラの責務
    外なので、そもそもコントローラには書けない
  3. 責務分離: 外部認証プロトコルの実装はアプリのドメインロジックではなく、Rails慣習では外部I/Oアダプタは lib/
    配下が定位置

つまり「fat controllerを避けるための切り出し」ではなく、そもそもコントローラの責務外の処理を然るべき層に置いた設計です。

require "omniauth-oauth2"
require "net/http"
require "json"
require "securerandom"

module OmniAuth
  module Strategies
    class Line < OmniAuth::Strategies::OAuth2
      option :name, "line"
      option :scope, "profile openid email"

      option :client_options, {
        site: "https://api.line.me",
        authorize_url: "https://access.line.me/oauth2/v2.1/authorize",
        token_url: "https://api.line.me/oauth2/v2.1/token"
      }

      uid { raw_info["userId"] }

      info do
        {
          name: id_token_info["name"] || raw_info["displayName"],
          email: id_token_info["email"],
          image: raw_info["pictureUrl"]
        }
      end

      extra do
        { raw_info: raw_info, id_token_info: id_token_info }
      end

      # nonceを発行して認可URLに付与(リプレイ攻撃対策)
      def authorize_params
        super.tap do |params|
          nonce = SecureRandom.hex(16)
          session["omniauth.line.nonce"] = nonce
          params[:nonce] = nonce
        end
      end

      # Renderなどリバースプロキシ下ではfull_hostが正しく取れないため、
      # 本番では環境変数で完全一致のcallback_urlを指定する
      def callback_url
        options[:callback_url].presence ||
          ENV["LINE_CALLBACK_URL"].presence ||
          (full_host + script_name + callback_path)
      end

      def raw_info
        @raw_info ||= access_token.get("/v2/profile").parsed || {}
      rescue ::OAuth2::Error, ::Timeout::Error, ::SystemCallError, ::SocketError => e
        log :error, "raw_info fetch failed: #{e.class}: #{e.message}"
        {}
      end

      def id_token_info
        @id_token_info ||= verify_id_token
      end

      private

      # LINEのverify APIにid_tokenを投げて検証付きでpayloadを取得
      def verify_id_token
        id_token = access_token.params["id_token"]
        return {} if id_token.blank?

        res = Net::HTTP.post_form(
          URI("https://api.line.me/oauth2/v2.1/verify"),
          id_token: id_token,
          client_id: options.client_id
        )
        unless res.is_a?(Net::HTTPSuccess)
          log :error, "verify API non-success status: #{res.code}"
          return {}
        end

        payload = JSON.parse(res.body)
        expected_nonce = session.delete("omniauth.line.nonce")
        # sessionにnonceが無いケースも含めて拒否(リプレイ攻撃対策)
        if expected_nonce.blank? || payload["nonce"] != expected_nonce
          log :error, "nonce verification failed"
          return {}
        end

        payload
      rescue JSON::ParserError => e
        log :error, "verify API JSON parse error: #{e.message}"
        {}
      rescue ::Timeout::Error, ::SystemCallError, ::SocketError => e
        log :error, "verify API network error: #{e.class}: #{e.message}"
        {}
      end
    end
  end
end

実装ポイント解説

scopeに openid email を含める

option :scope, "profile openid email"

openid を付けるとLINE側が id_token(JWT)を発行 してくれます。emailは id_token に含まれて返ってくる仕様。

email スコープ自体は LINE Developersコンソールで別途申請が必要 な点に注意してください。

nonceでリプレイ攻撃対策

OAuth 2.0 の state パラメータがCSRF対策なのに対し、nonceid_token の再利用攻撃 を防ぐOpenID
Connectの仕組みです。

項目 state nonce
目的 CSRF対策 リプレイ攻撃対策
仕様 OAuth 2.0 OpenID Connect
検証先 callbackクエリ id_tokenのpayload
def authorize_params
  super.tap do |params|
    nonce = SecureRandom.hex(16)
    session["omniauth.line.nonce"] = nonce
    params[:nonce] = nonce
  end
end

検証側では session.delete使い切り にします。

expected_nonce = session.delete("omniauth.line.nonce")
if expected_nonce.blank? || payload["nonce"] != expected_nonce
  return {}
end

expected_nonce.blank? チェックも重要で、「攻撃者が新しいセッションから盗んだid_tokenを使うケース」を弾く保険になります。

id_token検証はLINEのverify APIに委譲

自前でJWT署名検証する代わりに、LINEが提供する verify API に投げます。

POST https://api.line.me/oauth2/v2.1/verify
  id_token=...
  client_id=...

JWT検証で本来やるべき5項目

  1. 署名検証(公開鍵で)
  2. iss(発行者)の検証
  3. aud(クライアントID)の検証
  4. exp(有効期限)の検証
  5. nonce(リプレイ対策)の検証

このうち 1〜4 はLINE側がやってくれる ので、自分でやるのは nonce検証だけ で済みます。jwt gem も不要。

トレードオフとしては、検証時に毎回LINEに1リクエスト飛ぶ点ですが、ログイン頻度なら無視できる範囲です。

callback_url を環境変数で固定

Renderなどリバースプロキシ下で動かす場合、full_host がhttp/httpsを誤判定するケースがあります。LINE
Developers側に登録したcallback URLと 1文字違いでもエラー になるため、本番環境では環境変数で完全一致を強制しています。

def callback_url
  options[:callback_url].presence ||
    ENV["LINE_CALLBACK_URL"].presence ||
    (full_host + script_name + callback_path)
end

2. Devise設定

config/initializers/devise.rb で自前Strategyを読み込み&登録します。

# ファイル先頭で require
require Rails.root.join("lib/omniauth/strategies/line")

Devise.setup do |config|
  # ...

  config.omniauth :line,
                  ENV["LINE_CHANNEL_ID"],
                  ENV["LINE_CHANNEL_SECRET"]
end

OmniAuthの命名規則(:lineOmniAuth::Strategies::Line)で自動解決されます。


3. ルーティング

config/routes.rb でDeviseの標準ルートに加えて、メアド補完用のルートを追加します。

Rails.application.routes.draw do
  devise_for :users, controllers: {
    registrations: "users/registrations",
    omniauth_callbacks: "users/omniauth_callbacks"
  }

  devise_scope :user do
    get  "users/auth/line/email_setup",
         to: "users/omniauth_callbacks#line_email_setup",
         as: :line_email_setup
    post "users/auth/line/complete",
         to: "users/omniauth_callbacks#line_complete",
         as: :line_complete
  end
end

devise_scope :user
ブロック内に置くのがポイント。Deviseのcallback群と同じ名前空間に並べることで、認証コンテキストを共有できます。


4. Callbackコントローラ

app/controllers/users/omniauth_callbacks_controller.rb です。

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def line
    auth = request.env["omniauth.auth"]

    # メアドが取れていなければ補完画面へ
    if auth.info.email.blank?
      session["devise.line_data"] = auth.except("extra").to_hash
      redirect_to line_email_setup_path and return
    end

    user = User.from_omniauth(auth)

    if user&.persisted?
      sign_in_and_redirect user, event: :authentication
      set_flash_message(:notice, :success, kind: "LINE") if is_navigational_format?
    else
      session["devise.line_data"] = auth.except("extra").to_hash
      redirect_to new_user_registration_url,
                  alert:
"LINEログインに失敗しました。同じメールアドレスのアカウントが既に登録されている可能性があります。"
    end
  end

  # LINEからメアドを取得できなかった場合に表示する補完フォーム
  def line_email_setup
    @auth_data = session["devise.line_data"]
    if @auth_data.blank?
      redirect_to new_user_session_path,
                  alert: "セッションが無効です。再度LINEログインからお試しください。" and return
    end
  end

  # 補完フォームの送信処理
  def line_complete
    auth_data = session["devise.line_data"]
    if auth_data.blank?
      redirect_to new_user_session_path,
                  alert: "セッションが無効です。再度LINEログインからお試しください。" and return
    end

    auth = OmniAuth::AuthHash.new(auth_data)
    email = params.dig(:user, :email).to_s.strip

    user = User.create_from_omniauth_with_email(auth, email)

    if user&.persisted?
      session.delete("devise.line_data")
      sign_in_and_redirect user, event: :authentication
      set_flash_message(:notice, :success, kind: "LINE") if is_navigational_format?
    else
      @auth_data = auth_data
      flash.now[:alert] = "メールアドレスが既に登録されているか、入力内容が無効です。"
      render :line_email_setup, status: :unprocessable_content
    end
  end

  def failure
    redirect_to new_user_session_path, alert: "認証に失敗しました。もう一度お試しください。"
  end
end

ポイント

  • auth.except("extra") でraw_infoを除外してセッション肥大化を防止
  • セッションから取り出した HashOmniAuth::AuthHash.new で再構築してメソッドアクセスを復活
  • 補完フォーム失敗時は status: :unprocessable_content で再描画(Turbo対応)

5. Userモデル

app/models/user.rb です。

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :omniauthable, :trackable,
         omniauth_providers: [ :google_oauth2, :line ]

  validates :name, presence: true

  def self.from_omniauth(auth)
    # email_verifiedはGoogle固有のフィールド。LINEはverify APIで検証済みなので不要
    return nil if auth.provider == "google_oauth2" && !auth.info.email_verified
    return nil if auth.info.email.blank?

    user = find_by(provider: auth.provider, uid: auth.uid)
    return user if user

    return nil if User.exists?(email: auth.info.email)

    create do |u|
      u.provider = auth.provider
      u.uid = auth.uid
      u.email = auth.info.email
      u.name = auth.info.name
      u.password = SecureRandom.hex(16)
    end
  end

  # LINEログインでemailが取得できなかったときの補完用
  def self.create_from_omniauth_with_email(auth, email)
    return nil if email.blank?
    return nil if exists?(email: email)

    create do |u|
      u.provider = auth.provider
      u.uid = auth.uid
      u.email = email
      u.name = auth.info.name.presence || "ユーザー"
      u.password = SecureRandom.hex(16)
    end
  end

  def password_required?
    super && provider.blank?
  end

  def password_changeable?
    provider.blank?
  end
end

設計判断

既存メアドとの自動紐付けはしない

return nil if User.exists?(email: auth.info.email)

OAuthログインで「既存パスワード認証ユーザーと同じメアド」が来た場合、自動で紐付けず失敗 させています。

これは、「攻撃者がLINE側で被害者のメアドを偽装登録→そのLINEアカウントでログイン→既存アカウント乗っ取り」を防ぐためです。

LINEは verify API
で検証済みなので技術的には信頼できますが、運用ポリシーとして明示的な紐付け操作を要求する方が安全と判断しました。

パスワード関連の挙動を上書き

def password_required?
  super && provider.blank?
end

def password_changeable?
  provider.blank?
end

OAuthユーザーはパスワード未設定扱いとし、変更画面も非表示にします。password カラム自体はDeviseのvalidationを通すため
SecureRandom.hex(16) でランダム値を入れておきます。


6. メアド補完画面のView

app/views/users/omniauth_callbacks/line_email_setup.html.erb です。

<div class="flex items-center justify-center py-12 px-4">
  <div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
    <h2 class="text-2xl font-bold text-center text-gray-800 mb-2">
      メールアドレスの登録
    </h2>
    <p class="text-sm text-gray-600 text-center mb-6">
      LINEからメールアドレスを取得できませんでした。<br>
      ご利用のメールアドレスを入力してください。
    </p>

    <% if flash.now[:alert].present? || flash[:alert].present? %>
      <div class="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded">
        <%= flash.now[:alert] || flash[:alert] %>
      </div>
    <% end %>

    <% line_name = @auth_data&.dig("info", "name") %>
    <% if line_name.present? %>
      <p class="text-sm text-gray-700 mb-4">
        LINEアカウント名: <strong><%= line_name %></strong>
      </p>
    <% end %>

    <%= form_with url: line_complete_path, method: :post, local: true do |f| %>
      <div class="mb-4">
        <%= f.label "user[email]", "メールアドレス",
            class: "block text-sm font-medium text-gray-700 mb-1" %>
        <%= f.email_field "user[email]",
            autofocus: true,
            autocomplete: "email",
            required: true,
            class: "w-full px-3 py-2 border border-gray-300 rounded-md" %>
      </div>

      <%= f.submit "メールアドレスを登録してログイン",
          class: "w-full text-white py-2 px-4 rounded-md",
          style: "background-color: #06C755;" %>
    <% end %>
  </div>
</div>

LINEブランドカラー #06C755 で統一感を出しています。


7. 環境変数

.env.example に追加します。

LINE_CHANNEL_ID=
LINE_CHANNEL_SECRET=
LINE_CALLBACK_URL=
  • LINE_CHANNEL_ID / LINE_CHANNEL_SECRET : LINE Developersコンソールで発行
  • LINE_CALLBACK_URL : 本番のみ必須(リバースプロキシ環境での完全一致用)

ハマりどころ

1. emailスコープの申請が必要

LINE Developersのチャネル設定で 「メールアドレス取得権限」 を別途申請する必要があります。申請が通っていないと、scopeに
email を指定してもエラーになります。

2. callback URLの完全一致

LINE側に登録したcallback URLと、アプリ側が返すcallback URLは クエリ含め完全一致 が必要です。https
http、末尾スラッシュの有無まで揃えてください。

3. Zeitwerkの命名衝突

lib/omniauth/strategies/line.rb を置くとき、Zeitwerk autoload と OmniAuth 定数(実体は OmniAuth だが、ファイル名は
omniauth)が衝突することがあります。

config/application.rb で対象ディレクトリを autoload 対象から外すなどの対応が必要です。

Rails.autoloaders.main.ignore(Rails.root.join("lib/omniauth"))

まとめ

  • omniauth-line gemに依存せず、自前 Strategy で 100行未満で実装できた
  • id_token検証は LINE の verify API に委譲することで jwt gem 不要
  • LINE特有の「emailが取れないケース」に対応するため、メアド補完フローを追加
  • 既存メアドとの自動紐付けはせず、明示的な別アカウント扱いとした

セキュリティ面では nonce でのリプレイ攻撃対策、User.exists?
での既存メアド衝突回避を入れています。LINEログイン実装の参考になれば幸いです。


参考記事

1
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
1
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?