3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINEログイン実装【omniauth-oauth2 + 独自ストラテジ】

3
Posted at

RailsアプリにLINEログインを実装する方法を、コードを1つずつ解説しながら紹介します。
この記事はプログラミング学習初心者が書いた記事になります。

はじめに

対象

  • Rails アプリにLINE外部ログインを入れたい方
  • Devise でメール/パスワード認証は作ったことがある方

環境

  • Ruby 3.3 / Rails 8.1
  • Devise(認証)
  • 環境変数管理にdotenv-railsを使用
  • Renderにてデプロイ済み

前提条件

  • Deviseでのメール/パスワード認証は導入済み

前提知識

そもそもLINEログイン(OAuth)とは?

パスワードを自分のアプリで預からず、「本人確認」だけをLINEに代行してもらう仕組み です。

ユーザーは LINE に対してだけパスワードを入力し、アプリは LINE から「本人である」という証明だけを受け取ります。アプリはパスワードを一切保持しないため、安全性が高く、ユーザーも簡単にログインできることから、多くのアプリで採用されています。
OAuthとは外部サービスのパスワードを預からず、安全に認可を行うための標準プロトコルのことで、現在はアクセストークンを使うOAuth 2.0が主流となっています。LINEログインは OpenID Connect にも対応しており、openid スコープを使うとIDトークンによる認証も可能です(本記事では使いません)。

全体の流れ

① ユーザーが「LINEでログイン」をクリック

② アプリサーバーがstate認可URLを生成し、LINE の認可画面へリダイレクト

③ LINE上でユーザー認証

④ LINE上でこのアプリサーバーへ情報提供してよいかユーザーに確認

⑤ LINEがstate認可コードをつけてコールバックURLへユーザーを送り返す

⑥ アプリサーバーがstateを検証し、認可コードをトークンURLへ送ってアクセストークンを受け取る

⑦ アプリサーバーがアクセストークンでユーザーのプロフィール(名前・アイコン)を取得

⑧ その情報でアプリにユーザーを作成 or ログイン

この記事で実際にコードを書くのは主に①⑦⑧の部分です。
③④⑤はLINE上で行われます。
②⑥の通信は、後述する土台ライブラリ omniauth-oauth2 が担当してくれます。
stateとは、CSRF攻撃を防ぐために必要なランダムな文字列で「この認可レスポンスは、このブラウザが自分で開始したフローの続きかどうか」を判別するためのものです。

下準備

LINE Developers でチャネルを作る

コードを書く前に、LINE側で「アプリの登録」が必要です。

  1. LINE Developers にログイン
  2. プロバイダーを作成 →「LINEログイン」チャネルを作成
  3. Channel ID と Channel Secret を記録しておく
  4. 「LINEログイン設定」のコールバックURLに以下を登録

開発環境:https://gapy-unvenially-mabelle.ngrok-free.dev/users/auth/line/callback
本番環境:https://あなたのドメイン/users/auth/line/callback
今回の記事では開発環境にngrokを使用しており、ローカルで起動しているプロセスへインターネットを介して外部からアクセスできるようにしています。LINEログインはhttpでのエンドポイントを設定するとエラーになってしまいます。

コールバックURLとは
LINEでの認証が終わったあとの戻り先のURLのことです。

環境変数設定

.env.gitignore に入れてGit管理外にします。

.env(開発環境用)
LINE_LOGIN_KEY='あなたのChannel ID'
LINE_LOGIN_SECRET='あなたのChannel Secret'

本番環境用の環境変数はデプロイ先のRenderに登録しています。

gem導入

Gemfile に以下を追加します。

gemfile
# 認証の土台 - すでに導入済み前提
gem "devise"

# OAuth 2.0の共通処理を実装したOmniAuth用ライブラリ
gem "omniauth-oauth2"

# OmniAuth の CSRF(なりすまし)対策
gem "omniauth-rails_csrf_protection"

group :development, :test do
  gem "dotenv-rails"
end

bundle install

LINEログイン機能を含んだgemであるomniauth-line はメンテナンスされていないため使用せず、土台の omniauth-oauth2 の上に独自ストラテジを実装します。

実装

LINE 以外の OmniAuth プロバイダーも今後追加する前提で実装しています。

userテーブルにカラム追加

rails generate migration AddColumnToUsers

users テーブルに、プロバイダー名と LINE の固有 ID を保存するカラムを追加し、その組み合わせにユニーク制約をかけます。

db/migrate/xxxxxxxxxxx_add_column_to_users.rb
class AddColumnToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :provider, :string
    add_column :users, :uid, :string
    add_index :users, [ :provider, :uid ], unique: true
  end
end
rails db:migrate

ルーティング 

devise_for :users が/users/auth/line/users/auth/line/callbackの経路を自動生成してくれます。

config/routes.rb
devise_for :users, controllers: {
    omniauth_callbacks: "users/omniauth_callbacks"
  }

Userモデル

外部ログインを有効化します。

app/models/user.rb
  devise :omniauthable, omniauth_providers: %i[line]

今後プロバイダーが増える可能性があったため、配列で指定しています。

イニシャライザにプロバイダ登録

config/initializers/devise.rb
require Rails.root.join("lib/omniauth/strategies/line")
Devise.setup do |config|
...
  config.omniauth :line, ENV["LINE_LOGIN_KEY"], ENV["LINE_LOGIN_SECRET"], scope: "profile"

冒頭のrequireで、後述する自前ストラテジを明示的に読み込んでいます。

LINEログインボタン

<%- if devise_mapping.omniauthable? %>
  <%= button_to omniauth_authorize_path(resource_name, :line),
      method: :post,
      data: { turbo: false },
      ... do %>

    <%= image_tag "line_login_btn.png",
      alt: "LINEでログイン",
      ... %>
  <% end %>
<%- end %>
devise_mapping.omniauthable? とは

Devise が用意したメソッドで、このモデルは外部ログイン(OmniAuth)対応かを true / false で返します。
deviseのソースコード app/controllers/devise_controller.rb を見に行くと73-75行目に次のように定義されています。

def devise_mapping
    @devise_mapping ||= request.env["devise.mapping"]
end

devise_mappingは現在のリクエストに対応するDeviseの設定情報を取り出すメソッドです。
中身は Devise::Mapping のインスタンスで、これはモデルごとの Devise 設定を保持するクラスです(Devise はモデルごとに異なる設定が可能。本アプリは devise_for :users の users のみ)。
つまり、devise_mapping.omniauthable?でこのインスタンスに対しOmniAuth機能が有効かどうかを問い合わせています。
app/models/user.rbdevise :omniauthable, ...と書いてあれば true → ボタンが表示される。外部ログインが有効な時だけ表示される仕組みになっています。

button_toを使う理由

OAuthの認証フローはCSRF対策のためPOSTリクエストが推奨されています。button_to はデフォルトでPOSTを使用しますが、明示的に書くことでコードの意図を明確にしています。
omniauth_authorize_path(resource_name, :line) は第一引数にリソース名(:user)、第二引数にプロバイダー名を指定しパスを作るヘルパーメソッドです。内部で処理されuser_line_omniauth_authorize_pathというパスを作ります。
rails routesで確認すると

user_line_omniauth_authorize POST|GET /users/auth/line(.:format) 

/users/auth/line というURLが生成されることがわかります。

独自ストラテジ

lib/omniauth/strategies/line.rb
require "omniauth-oauth2"
require "json"

module OmniAuth
  module Strategies
    # OAuth2 の共通処理(トークン交換・state 検証等)は omniauth-oauth2 に委譲し、LINE 固有の差分のみここで定義
    class Line < OmniAuth::Strategies::OAuth2
      option :name, "line"
      option :scope, "profile"

      option :client_options, {
        site: "https://api.line.me",  # ベースURL
        authorize_url: "https://access.line.me/oauth2/v2.1/authorize",  # 認可URL
        token_url: "/oauth2/v2.1/token"  # トークン取得のURL
      }

      def callback_url
        full_host + script_name + callback_path
      end

      uid { raw_info["userId"] }

      info do
        {
          name:        raw_info["displayName"]
        }
      end

      # PROFILE 権限付きのアクセストークンで LINE プロフィールを取得する。
      def raw_info
        @raw_info ||= JSON.parse(access_token.get("v2/profile").body)
      end
    end
  end
end

上から順に見ていきます。

require

require "omniauth-oauth2"
require "json"

omniauth-oauth2という土台ライブラリを読み込んでいます。
LINEからの返答はJSON形式で受け取るためjsonも読み込む必要があります。

オプション設定

option :name, "line"
option :scope, "profile"

このストラテジの名前と、LINEへプロフィール情報へのアクセスを要求するスコープのオプション設定を書いています。

client_options(プロバイダ固有のURL)

option :client_options, {
        site: "https://api.line.me",  # ベースURL
        authorize_url: "https://access.line.me/oauth2/v2.1/authorize",  # 認可画面URL
        token_url: "/oauth2/v2.1/token"  # トークン取得のURL
      }

callback_urlを オーバーライド

def callback_url
  full_host + script_name + callback_path
end
オーバーライドする理由

土台となるomniauthのコード504行目を見に行くと

def callback_url
  full_host + callback_path + query_string
end

となっており、redirect_uriとして送る時にもquery_string付きになってしまいます。認可リクエスト時は query_string が空なので、redirect_uri は
https://example.com/users/auth/line/callback
になります。一方、コールバック後にトークン交換を行うタイミングでは、
/users/auth/line/callback?code=xxx&state=yyy
にアクセスしているため、query_string が付与されます。その結果、
redirect_uri=https://example.com/users/auth/line/callback?code=...&state=...
が送られてしまいます。
LINEは redirect_uri が「認可リクエスト時」と「トークン交換時」で完全一致しないと弾かれるため、オーバーライドしてquery_stringを除く必要があります。

項目 意味
full_host プロトコル+ホスト名(必要ならポート番号も含む) https://example.com
script_name アプリのマウント接頭辞(通常は空文字列) """/app"
callback_path OmniAuthのコールバック先のパス /users/auth/line/callback

LINEのAPIレスポンスを共通の形式に変換

def raw_info
  @raw_info ||= JSON.parse(access_token.get("v2/profile").body)
end

コールバックで戻ってきた時点で access_token は取得済みです。そこから .get("v2/profile") でプロフィール情報を取りに行き、レスポンスボディの JSON 文字列を Ruby のハッシュに変換しています。@raw_info ||= はメモ化で、2回目以降の呼び出しでは API を叩き直しません。

uid { raw_info["userId"] }

info do
  {
    name:        raw_info["displayName"] 
  }
end

そのハッシュに格納された値のなかで不変であるuserIduidとしてユーザー識別子にしています。
infoはomniauth共通のスキーマ(今回はnameのみ)に正規化します。

独自ストラテジをZeitwerkの管理から外す

config/application.rb
module Myapp
  class Application < Rails::Application
    config.autoload_lib(ignore: %w[assets tasks omniauth])
  end
end

Rails 7.1 以降は config.autoload_lib により lib 配下が自動読み込みの対象になっています。
Zeitwerkとは「ファイル名からクラス名を判断してrequireしてくれるローダー」でRails6から標準で入っています。omniauthをZeitwerkの管理から外します。

Zeitwerkの管理から外す理由

railsはlib/omniauth/strategies/line.rb に対して Omniauth::Strategies::Line という定数を期待しますが、実際の定数は OmniAuth::Strategies::Line(Aが大文字)となり、Zeitwerk::NameError が発生します。

❯ dc exec web rails zeitwerk:check
Hold on, I am eager loading the application.
bin/rails aborted!
NameError: uninitialized constant Omniauth::Strategies::Line (NameError)

    @mod.const_get(@cname, false)
        ^^^^^^^^^^

Tasks: TOP => zeitwerk:check
(See full trace by running task with --trace)

対策としてomniauthをZeitwerkの管理から外します。Rails8ではconfig/application.rbconfig.autoload_lib(ignore: %w[assets tasks])が最初から書かれているので、omniauth を追記するだけです。
devise.rb で明示的にrequireしているので管理から外しても問題なく読み込まれます。

コールバックコントローラー

app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def line
    callback_for(:line) # LINE から戻ってきたときの入口
  end

  private

  def callback_for(provider)
  # 認証結果からユーザーを取得/作成(app/models/user.rb に定義)
    user = User.from_omniauth(request.env["omniauth.auth"])
    if user.persisted?
      sign_in user, event: :authentication
      flash[:notice] = "#{provider_name(provider)}でログインしました"
      redirect_to stored_location_for(user) || authenticated_root_path
    else
      session["devise.#{provider}_data"] = request.env["omniauth.auth"].except(:extra)
      redirect_to new_user_session_path, alert: "ユーザー情報の取得に失敗しました。"
    end
  end

  def provider_name(provider)
    {
      line: "LINE",
      google_oauth2: "Google"
    }.fetch(provider.to_sym, provider.to_s.titleize)
  end
end

callback_forprovider_name はアクションではないので private 配下に置いています。
LINE専用の処理は書かず、共通処理として callback_for を呼び出しています。

if user.persisted?
    sign_in user, event: :authentication                   # ログイン
    flash[:notice] = "#{provider_name(provider)}でログインしました"
    redirect_to stored_location_for(user) || authenticated_root_path  # 元居た場所 or ダッシュボード
  else
    session["devise.#{provider}_data"] = request.env["omniauth.auth"].except(:extra)
    redirect_to new_user_session_path, alert: "ユーザー情報の取得に失敗しました。"
  end

ユーザーの保存可否を分岐しています。保存できていればログインして元のページ(なければダッシュボード)へ、失敗していれば認証情報をセッションに退避してログイン画面へ戻します。

ユーザー生成

app/models/user.rb
 def self.from_omniauth(auth)
   user = find_or_initialize_by(provider: auth.provider, uid: auth.uid)
   return user unless user.new_record?

   user.email    = auth.info.email.presence || "#{auth.uid}-#{auth.provider}@example.com"
   user.name     = auth.info.name.presence  || default_name_for(auth.provider)
   user.password = Devise.friendly_token[0, 20]
   user.save
   user
 end
 
def self.default_name_for(provider)
   {
     "line" => "LINEユーザー"
   }.fetch(provider, "ユーザー")
 end

provider + uid の組み合わせでユーザーを識別し、2回目以降のログインでは既存ユーザーをそのまま返します。find_or_initialize_byは条件を指定して初めの1件を取得し1件もなければ新しいインスタンスを作るメソッドです。

初回作成時の各カラムは以下の通りにしています。

  • メールアドレス:今回の設計ではLINEからメールアドレスを取得できないため、uidとプロバイダー名から仮アドレスを作成しています。
  • 名前:取得失敗した場合に備えてダミー名にフォールバックしています。
  • パスワード:外部ログインユーザーは使わないため、ランダム値を入れます。

実際のメールアドレスを取得したい場合は、scope に openid と email を追加したうえで、LINE Developers コンソールでメールアドレス取得権限の申請が必要です。取得は ID トークン経由になるため、本記事の構成とは別の実装が必要になります。

まとめ - 全体の流れ

ユーザーが「LINEでログイン」ボタンを押す
button_to から /users/auth/line に POST リクエストを送る。

devise_for がリクエストを受け取り、OmniAuth::Strategies::Line を起動する

omniauth-oauth2 が認可URL・state・redirect_uri などを生成し、LINEの認可画面 (https://access.line.me/...) へリダイレクトする

ユーザーがLINEでログインし、アプリへの情報提供に同意する

LINEが codestate を付与してコールバックURLへリダイレクトする

omniauth-oauth2state を検証し、認可コード (code) を使って https://api.line.me/oauth2/v2.1/token にアクセストークンを要求する
 このとき、本記事でオーバーライドした callback_urlredirect_uri として使用される。

アクセストークンを利用して v2/profile API を呼び出し、userIddisplayName を取得する

OmniauthCallbacksControllerUser.from_omniauth を呼び出し、ユーザーを新規作成または既存ユーザーとしてログインさせる

開発環境で試してみてください。ログインできていれば成功です。
何か間違いがありましたらお気軽にコメント頂けますと幸いです。

参考文献

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?