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を採用しなかったか
選定時に検討しましたが、以下の理由で見送りました。
- 最終更新が5年以上前で実質メンテ停止
-
emailスコープ未対応
全体構成
[ユーザー]
↓ LINEログインボタン押下
[LINE認可画面]
↓ 承認後 callback
[OmniauthCallbacksController#line]
├─ email 取れた → User作成 → ログイン完了
└─ email 取れない → セッションに退避 → メアド補完画面へ
↓
[メアド入力フォーム]
↓
[#line_complete] → User作成 → ログイン完了
1. 自前OmniAuth Strategy
lib/omniauth/strategies/line.rb に実装します。
なぜ lib/omniauth/strategies/ 配下に置くのか
このディレクトリ・名前空間にはいくつか必然性があります。
-
OmniAuthの規約: Deviseの
config.omniauth :lineはOmniAuth::Strategies::Line
を自動解決するため、この名前空間は必須 -
Rackミドルウェア層の住人: Strategyはコントローラに到達する前に動く。token取得やid_token検証はコントローラの責務
外なので、そもそもコントローラには書けない -
責務分離: 外部認証プロトコルの実装はアプリのドメインロジックではなく、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対策なのに対し、nonce は id_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項目:
- 署名検証(公開鍵で)
-
iss(発行者)の検証 -
aud(クライアントID)の検証 -
exp(有効期限)の検証 -
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の命名規則(:line → OmniAuth::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を除外してセッション肥大化を防止 - セッションから取り出した
HashはOmniAuth::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-linegemに依存せず、自前 Strategy で 100行未満で実装できた - id_token検証は LINE の verify API に委譲することで
jwtgem 不要 - LINE特有の「emailが取れないケース」に対応するため、メアド補完フローを追加
- 既存メアドとの自動紐付けはせず、明示的な別アカウント扱いとした
セキュリティ面では nonce でのリプレイ攻撃対策、User.exists?
での既存メアド衝突回避を入れています。LINEログイン実装の参考になれば幸いです。
参考記事