歡迎來到第十七天!在前幾天完成了遊戲大廳、兩款核心小遊戲與後端防弊機制後,今天我們要解決一個非常接地氣的商業難題:「顧客在實體門市裡,如何以最短的路徑進入遊戲,並完成會員身分綁定?」
如果要求顧客在店裡下載 100MB 以上的原生 App,放棄率通常高達 80% 以上。因此,我們選擇了 LINE LIFF (LINE Front-end Framework) 作為載體。顧客只需要拿起手機掃描桌上的 QR Code,即可在 LINE 內建的 WebView 中瞬間啟動 Cocos 網頁遊戲。
為了保護使用者資料並防範偽造身分,我們採用了「LINE OAuth2 認證 + 本地 Ktor JWT 簽發」的雙重鑑權架構:
+------------------+ +-------------------+ +-------------------+ +------------------+
| 顧客手機 (LIFF) | | Kotlin Ktor 後端 | | LINE Platform | | PostgreSQL 資料庫|
+------------------+ +-------------------+ +-------------------+ +------------------+
| | | |
| --- (1) 初始化 LIFF SDK ----> | | |
| <-- (2) 回傳 id_token -------| | |
| | | |
| --- (3) POST /api/v1/auth/login (idToken) -----------------> | |
| | --- (4) 驗證 idToken --------> | |
| | <-- (5) 回傳 LINE Profile -- | |
| | | |
| | --- (6) Upsert 使用者資料 -------------------------------> |
| | <-- (7) 回傳 User Record --------------------------------- |
| | | |
| <-- (8) 簽發系統 JWT --------| | |
在 Cocos 大廳的初始化腳本 LiffManager.ts 中,我們處理 SDK 加載與 ID Token 取得,並發送至後端進行雙重鑑權:
import liff from '@line/liff';
export class LiffManager {
private static jwtToken: string = "";
public static async initLiffAndAuthenticate(): Promise<string> {
try {
// 1. 初始化 LINE LIFF SDK
await liff.init({ liffId: "1234567890-AbcdEfgh" });
// 2. 判斷使用者是否已在 LINE 中登入
if (!liff.isLoggedIn()) {
liff.login({ redirectUri: window.location.href });
return "";
}
// 3. 取得 LINE ID Token (JWT 格式)
const idToken = liff.getIDToken();
if (!idToken) {
throw new Error("無法取得 LINE ID Token");
}
// 4. 將 ID Token 發送給 Ktor 後端換取系統專屬 JWT
this.jwtToken = await this.exchangeLineTokenForJwt(idToken);
// 5. 將 JWT 儲存在 SessionStorage
sessionStorage.setItem("APP_JWT_TOKEN", this.jwtToken);
return this.jwtToken;
} catch (error) {
console.error("[LIFF Auth Error]", error);
throw error;
}
}
private static async exchangeLineTokenForJwt(idToken: string): Promise<string> {
const response = await fetch("https://api.yourstore.com/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken })
});
if (!response.ok) {
throw new Error(`鑑權失敗 HTTP Status: ${response.status}`);
}
const resData = await response.json();
return resData.data.token; // 回傳系統高權限 JWT
}
}
在 Ktor 後端,我們透過 ktor-server-auth-jwt 與 ktor-client 實作嚴謹的雙重驗證,避免客戶端偽造身份:
// AuthRoutes.kt
fun Route.authRouting() {
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
post("/api/v1/auth/login") {
val request = call.receive<LineAuthRequest>()
// 1. 向 LINE 官方 Verify API 驗證 Token 合法性 (防止偽造 id_token)
val lineVerifyResponse: HttpResponse = httpClient.post("https://api.line.me/oauth2/v2.1/verify") {
setBody(FormDataContent(Parameters.build {
append("id_token", request.idToken)
append("client_id", System.getenv("LINE_CHANNEL_ID") ?: "")
}))
}
if (lineVerifyResponse.status != HttpStatusCode.OK) {
call.respond(HttpStatusCode.Unauthorized, "無效的 LINE ID Token")
return@post
}
val verifyData = lineVerifyResponse.body<LineVerifyResult>()
// 2. 資料庫 Upsert 操作 (若不存在則新增,若存在則更新登入時間)
val user = UserService.findOrCreateByLineId(verifyData.sub, verifyData.name ?: "萌新業主")
// 3. 簽發 7 天效期之 HMAC256 加密 JWT
val token = JWT.create()
.withAudience("my-store-game")
.withIssuer("https://api.yourstore.com/")
.withClaim("userId", user.id.toString())
.withExpiresAt(Date(System.currentTimeMillis() + 7 * 86_400_000L))
.sign(Algorithm.HMAC256("YOUR_JWT_SECRET_KEY"))
call.respond(HttpStatusCode.OK, mapOf("code" to 200, "data" to mapOf("token" to token, "userId" to user.id)))
}
}
Tech Tip:Apple 的 ITP (Intelligent Tracking Prevention) 隱私機制會限制第三方 WebView 中的
LocalStorage與Cookie存留時間(常在關閉頁面或 7 天後自動清空)。為了防止顧客再次掃碼時掉登入,絕不能單純依賴持久化 Cookie!最佳實作是在每次進入 LIFF 時,永遠以liff.getIDToken()作為信任根基(Root of Trust),無感向 Ktor 後端換取最新 JWT,確保體驗完全不中斷!