弦而時習之

使用 Ruby on Rails 開發 LINE LIFF 應用的登入處理

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

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

LIFF

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

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

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

IDToken

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

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

在 LIFF 中要獲取最佳的使用者體驗,使用 IDToken 會比 OAuth2 來得更好。這是因為自從 OAuth2 有了 # CVE-2015-9284 這個 CSRF 漏洞後,所有 OAuth2 登入都需要透過 POST 方式啟動並且加上 CSRF Token 才能動作,這個操作變相的會需要使用者點選「登入」按鈕。

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

LIFF Redirect

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

Primary Redirect

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

如果是在 LINE 裡面觸發,會是先把 IDToken 放進去方便後面動作,如果是一般瀏覽器開啟就需要額外呼叫 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() 並且處理登入,時機上有點太晚而且對後端來說非常不好處理。

登入處理

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 中就可以像這樣使用

1
2
3
4
document.addEventListener('turbolinks:load', () => {
  const app = new LIFFApp()
	app.run()
})

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

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

後端處理

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

我自己在這邊直接實作了 Warden::LINE 這個套件來輔助我驗證。

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 指定的頁面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 等新工具以純後端的方式開發,在幾乎不依靠前端的狀況下以後端實現大多數功能。

總結

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

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

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

Buy me a CoffeeBuy me a Coffee

電子報

留言