跳至主要內容
蒼時弦也
蒼時弦也
資深軟體工程師
發表於

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

這篇文章發表於 5 年前,文章內容可能已經過時,請謹慎參考。

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

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

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 這個 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() 並且處理登入,時機上有點太晚而且對後端來說非常不好處理。

登入處理

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

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

 1export default class LIFFApp {
 2  constructor() {
 3	  this.search = new URLSearchParams(window.location.search)
 4    this.hash = new URLSearchParams(window.location.hash)
 5	}
 6
 7	get isSecondary() {
 8    const state = this.search.get('liff.state')
 9    return state && state.length !== 0
10  }
11
12  get idToken() {
13    return this.hash.get('id_token')
14  }
15
16  get liffId() {
17    return document.body.dataset.liffId
18  }
19
20  async run() {
21    await this.doLogin()
22    await liff.init({ liffId: this.liffId })
23  }
24
25  doLogin() {
26    if (this.isSecondary) {
27      const form = new FormData()
28      form.append('id_token', this.idToken)
29
30      return fetch('/liff/sessions', {
31        method: 'POST',
32        body: form
33      })
34    }
35
36    return Promise.resolve()
37  }
38}

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

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

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

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

後端處理

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

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

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

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

 1module LIFF
 2  class SessionsController < LIFF::BaseController
 3    skip_forgery_protection only: :create
 4    skip_before_action :ensure_liff_user!, only: :create
 5
 6    def create
 7      # TODO: Rewrite Warden::LIFF to support Devise
 8      if warden.authenticate(:line, scope: :line)
 9        @user = LIFF::UserFinder.new(warden.user(:line)).perform
10        sign_in @user
11        head :no_content
12      else
13        head :unauthorized
14      end
15    end
16  end
17end

因為 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 指定的頁面。

 1module LIFF
 2  class BaseController < ApplicationController
 3    layout 'liff'
 4
 5    before_action :ensure_liff_user!
 6
 7    private
 8
 9    def ensure_liff_user!
10      return if user_signed_in?
11
12      render :not_found, locals: { reason: t('liff.shared.user_not_found') }
13    end
14  end
15end

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

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

總結

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

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

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