---
title: "使用 Ruby on Rails 開發 LINE LIFF 應用的登入處理"
date: 2021-09-21T00:00:00+08:00
publishDate: 2021-09-21T20:34:25+08:00
lastmod: 2025-10-19T15:27:28+08:00
tags: ["LINE","LIFF","Rails","Ruby"]
toc: true
permalink: "https://blog.aotoki.me/posts/2021/09/21/liff-with-login-base-on-ruby-on-rails/"
language: "zh-tw"
---


中秋連假的時候想到老爸的客戶大多是稍微有年紀的長輩，過去都是慢慢教會怎麼使用 Email 來註冊系統的，但在台灣 LINE 的普及率其實非常的高，如果能用 LIFF 並且免去註冊流程的話似乎是個不錯的選擇。

> 目前 LINE 的 Mini App 還沒開放，因此不知道是否能獲得比 LIFF 更好的開發體驗。

<!--more-->

## LIFF

LIFF 全名叫做 LINE Front-end Framework 簡單來說就是允許我們在 LINE 裡面製作一些前端應用的工具。

因為是以「前端」為前提的，因此使用 Vue.js / React 之類的開發一些小工具實際上也是蠻輕鬆的。

不過如果要整合後端，像是 Ruby on Rails 就會遇到一些不方便的。

## ID token

目前我們有三種方式可以跟 LINE 的帳號連結，分別是 Chatbot 的 Account Linking 模式、OAuth2 的 LINE Login 以及 LIFF 的 ID token。

扣掉 Account Linking 實際上 OAuth2 跟 ID token 有點是取得流程的不同。

在 LIFF 中要獲取最佳的使用者體驗，使用 ID token 會比 OAuth2 來得更好。這是因為自從 OAuth2 有了 [# CVE-2015-9284](https://nvd.nist.gov/vuln/detail/CVE-2015-9284) 這個 CSRF 漏洞後，所有 OAuth2 登入都需要透過 POST 方式啟動並且加上 CSRF Token 才能動作，這個操作變相的會需要使用者點選「登入」按鈕。

但是在 LIFF 中取得 ID token 雖然有點繞路，但是我們可以透過控制 LIFF 的 Redirect 流程來完成無縫登入。

## LIFF Redirect

LIFF 根據使用的情況會有一次到兩次的轉跳，並且不管怎樣都無法避免掉第一次轉跳。

### Primary Redirect

這個步驟是從 LINE 開啟 LIFF 應用時會從 `https://liff.line.me` 跳到實際的網站，同時會攜帶一些資訊到指定的頁面中。

> 如果是在 LINE 裡面觸發，會是先把 ID token 放進去方便後面動作，如果是一般瀏覽器開啟就需要額外呼叫 `liff.login()` 跑過一次類似 OAuth2 的流程。

### Secondary Redirect

如果是簡單的 Single Page Application 的話我們會直接呼叫 `https://liff.line.me/xxx` 那麼就只會有 Primary Redirect 的觸發，但是如果我們的應用是稍微複雜的，就會需要像是 `https://liff.line.me/xxx/profile?mode=liff` 的設計來開啟不同畫面，這就會觸發 Secondary Redirect 的動作。

在 LIFF 的流程基本上是這樣的

| 階段         | 動作                                  |
| ------------ | ------------------------------------- |
| liff.line.me | 生成 Token 並轉跳                     |
| Primary      | 紀錄資訊並等待 `liff.init()` 完成後轉跳 |
| Secondary    | 等待 `liff.init()` 完成後允許後續操作   |

因為上述的步驟是不可迴避的，如果到了 Secondary Redirect 結束後才呼叫 `liff.getIDToken()` 並且處理登入，時機上有點太晚而且對後端來說非常不好處理。

## 登入處理{#login-process}

在尋找解決方案的過程中，發現 Primary Redirect 階段拿到的 `window.location.search` 和 `window.location.hash` 裡面是帶有 ID token 可以使用的，因此我們可以稍微調整一下流程。

原本是當頁面載入時馬上做 `liff.init()` 來初始化 LIFF 應用，但我們加入幾個判斷，變成類似這樣的物件。

```js
export default class LIFFApp {
  constructor() {
	  this.search = new URLSearchParams(window.location.search)
    this.hash = new URLSearchParams(window.location.hash)
	}

	get isSecondary() {
    const state = this.search.get('liff.state')
    return state && state.length !== 0
  }

  get idToken() {
    return this.hash.get('id_token')
  }

  get liffId() {
    return document.body.dataset.liffId
  }

  async run() {
    await this.doLogin()
    await liff.init({ liffId: this.liffId })
  }

  doLogin() {
    if (this.isSecondary) {
      const form = new FormData()
      form.append('id_token', this.idToken)

      return fetch('/liff/sessions', {
        method: 'POST',
        body: form
      })
    }

    return Promise.resolve()
  }
}
```

我們在 Rails 中就可以像這樣使用

```js
document.addEventListener('turbolinks:load', () => {
  const app = new LIFFApp()
	app.run()
})
```

透過這樣的機制我們就可以在 Primary Redirect 的時候判斷是否要先呼叫一次登入的 API 還是直接執行就好。

> 目前的設計會讓 LIFF 入口不實現任何功能，單純處理登入機制。

## 後端處理{#backend-process}

從 LIFF 拿到的 ID token 官方不建議直接使用，因此需要再呼叫一次 API 到 LINE 的後端驗證，如果每次操作都要呼叫 API 可能會有效能問題，也不確定是否會有 Rate Limit 的限制。

我自己在這邊直接實作了 [Warden::LINE](https://github.com/elct9620/warden-line) 這個套件來輔助我驗證。

> 在這次的實驗發現回傳值跟 Devise 不相容，未來會更新到能被 Devise 吃到的狀態。

因為這次要搭配 Devise 使用，作為為來支援網頁版或者 App 的情境，因此除了使用 Warden 之外還需要跟 Devise 整合在一起，登入的流程也無法直接使用 Devise 的行為。

```ruby
module LIFF
  class SessionsController < LIFF::BaseController
    skip_forgery_protection only: :create
    skip_before_action :ensure_liff_user!, only: :create

    def create
      # TODO: Rewrite Warden::LIFF to support Devise
      if warden.authenticate(:line, scope: :line)
        @user = LIFF::UserFinder.new(warden.user(:line)).perform
        sign_in @user
        head :no_content
      else
        head :unauthorized
      end
    end
  end
end
```

因為 Devise 已經佔用走了 `user` scope 所以我們呼叫 Warden 的 `warden.authenticate` 時需要指定使用自訂的 `Warden::LINE` 規則以及套用在 `line` scope 上面。

另一方面 Devise 把 FailureApp 改掉，所以我們也無法用 `authenticate!` 來自動處理登入失敗，這邊改為用 Wardne 的登入結果判斷。

> 好像也可以直接呼叫 API 驗證，不過 `Warden::LINE` 是之前做其他專案實現的，所以就直接拿來使用。

至於 `LIFF::UserFinder` 會在找不到使用者時自動產生新的使用者，這邊就不多做說明。

有了這樣的機制，我們就可以在 `LIFF::BaseController` 加上 `ensure_liff_user!` 方法，確保所有使用 LIFF 的使用者都會先經過 `/liff` 的 Primary Redirect 呼叫 `/liff/sessions` 登入，再經過 Secondary Redirect 的流程進入 LIFF 指定的頁面。

```ruby
module LIFF
  class BaseController < ApplicationController
    layout 'liff'

    before_action :ensure_liff_user!

    private

    def ensure_liff_user!
      return if user_signed_in?

      render :not_found, locals: { reason: t('liff.shared.user_not_found') }
    end
  end
end
```

這邊我們可以簡單利用 Devise 提供的 `user_signed_in?` 來協助我們處理，同時因為沒有成功登入再完成 Secondary Redirect 時就會以錯誤畫面顯示，取代原本應該出現的功能。

實作起來也相對的簡單跟乾淨，剩下的就是繼續用 Rails 最近新推出的 Hotwire 等新工具以純後端的方式開發，在幾乎不依靠前端的狀況下以後端實現大多數功能。

## 總結{#conclusion}

這個方法算不上非常乾淨，但是在操作上可以利用「載入中」來消除使用者覺得卡頓的感覺，另一方面無縫的完成會員登入的操作也讓使用體驗變得簡單很多。

至於無法在 Query String 拿到 ID token 雖然有點可惜，但推測可能是在安全性上有所考量，原本想參考 LINE Taxi 但似乎已經不是 LIFF 的狀態。

只能期待之後可以使用 Mini App 的時候在這方便會有更好的開發體驗，以及能製作更流暢的應用。

