今天的開發重點是建立會員卡系統,將 LIFF 前端與 NestJS 後端完整串接。
前端使用 Vue 建立註冊表單,搭配 vee-validator 驗證欄位的合法性。當用戶完成填寫並點擊註冊後,系統將 LIFF 取得的 ID Token 傳遞至後端。
後端接收 ID Token 後,結合 LIFF Channel ID 透過 LINE 驗證 API 進行身份驗證。驗證成功後解析 ID Token 中的用戶編號,並以此編號作為用戶資料表的主鍵,完成前後端串接的範例應用。所有資料庫操作都透過 Supabase 處理。明天將整合電子郵件驗證碼功能,強化註冊流程的安全性。
前端 LIFF 的部分主要拆分成三個頁面處理:
LINE 提供兩種主要的用戶資料驗證方式:
本範例使用的是第一種的方式,我們會透過 LIFF 將 IDtoken 傳遞至後端,流程如下:
Supabase 官方網站
Supabase 是一個開源的後端即服務(BaaS)平台,提供開發者快速建立應用程式所需的後端基礎設施。它整合了 PostgreSQL 資料庫、身份驗證、即時訂閱、儲存空間等功能,讓開發者無需從零搭建後端環境,即可專注於應用邏輯的開發。
為了讓開發流程更清晰,本專案後端採用 Supabase 的 PostgreSQL 服務。資料庫的建立、管理與操作都透過 Supabase 提供的管理介面和工具完成,簡化配置與維護的複雜度。
【 Free plan 】
Step 1:創建專案
Database password
的部分設定後要記得
Step 2:點選左側導覽列中的 Database
Step 3:進入 Database
介面後,點選 Create a new table
創建資料表
Step 4:創建兩張資料表,分別紀錄驗證碼及用戶資訊
id 使用 LINE User ID 作為唯一值,後端解析 ID Token 後取得。
環境變數(env)
config 啟動驗證
/**
* 驗證模式
*/
const configSchema = Joi.object({
line: Joi.object({
channelId: Joi.string().required(),
loginVerifyUrl: Joi.string().uri().required(),
}).required(),
supabase: Joi.object({
url: Joi.string().uri().required(),
serviceRoleKey: Joi.string().required(),
}).required(),
});
/**
* 驗證模組
*/
export default () => {
const config = {
line: {
channelId: process.env.LINE_LOGIN_CLIENT_ID,
loginVerifyUrl: process.env.LINE_LOGIN_VERIFY_URL,
},
supabase: {
url: process.env.SUPABASE_URL,
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
},
};
const { error, value } = configSchema.validate(config, {
abortEarly: false,
});
if (error) throw new Error(`環境變數驗證錯誤: ${error.message}`);
return value;
};
請求注意事項:
liff.getIdToken()
方法取得 JWT 格式的 Token,傳遞至後端後,透過 LINE 驗證 API 解析取得用戶資訊ID Token 解析後主要欄位:
// 略
@Injectable()
export class LineLoginService {
// 略
async verifyIDToken(idToken: string): Promise<TokenVerifyResponse> {
const formData = new URLSearchParams();
formData.append('id_token', idToken);
formData.append(
'client_id',
this.configService.getOrThrow<string>('line.channelId'),
);
const responseData = await firstValueFrom(
this.httpService
.post(this.LINE_LOGIN_VERIFY_URL, formData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.pipe(
catchError((err: AxiosError) => {
return throwError(
() =>
new Error(
`LINE token verify API request failed: ${JSON.stringify(err.response?.data)}`,
),
);
}),
),
);
return responseData.data;
}
}
建立 Supabase 連線物件,供其他服務注入使用。為簡化操作,資料表未使用 RLS 機制,直接使用 Service Role Key 以管理者權限操作。
// 略
import { createClient, SupabaseClient } from '@supabase/supabase-js';
@Injectable()
export class SupabaseService {
private readonly client: SupabaseClient;
constructor(private readonly config: ConfigService) {
const url = this.config.getOrThrow<string>('supabase.url');
const key = this.config.getOrThrow<string>('supabase.serviceRoleKey');
this.client = createClient(url, key, { auth: { persistSession: false } });
}
get db() {
return this.client;
}
}
負責處理用戶相關的 API 邏輯,包含用戶註冊功能。前端透過 LIFF 以 Ajax 方式向後端發送請求。
使用 class-validator
動態驗證請求及回覆資料,確保符合規定格式。
請求參數說明
user/dto/register-user.dto.ts(請求資料)
import {
IsEmail,
IsNotEmpty,
IsString,
Matches,
MinLength,
MaxLength,
} from 'class-validator';
export class RegisterUserDto {
@IsString()
@IsNotEmpty({ message: 'LINE ID Token 為必填項目' })
idToken: string;
@IsString()
@IsNotEmpty({ message: '姓名為必填項目' })
@MinLength(2, { message: '姓名至少需要 2 個字元' })
@MaxLength(20, { message: '姓名不能超過 20 個字元' })
name: string;
@IsString()
@IsNotEmpty({ message: '電話為必填項目' })
@Matches(/^09\d{8}$/, {
message: '請輸入正確的台灣手機號碼格式 (09xxxxxxxx)',
})
phone: string;
@IsString()
@IsNotEmpty({ message: '生日為必填項目' })
birthday: string;
@IsEmail({}, { message: '請輸入正確的電子信箱格式' })
@IsNotEmpty({ message: '電子信箱為必填項目' })
email: string;
}
line-login/line-login.service.ts
// 略
@Injectable()
export class LineLoginService {
// 略
async verifyIDToken(idToken: string): Promise<TokenVerifyResponse> {
const formData = new URLSearchParams();
formData.append('id_token', idToken);
formData.append('client_id', this.LINE_LOGIN_CLIENT_ID);
const responseData = await firstValueFrom(
this.httpService
.post(this.LINE_LOGIN_VERIFY_URL, formData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.pipe(
catchError((err: AxiosError) => {
return throwError(
() =>
new Error(
`LINE token verify API request failed: ${JSON.stringify(err.response?.data)}`,
),
);
}),
),
);
return responseData.data;
}
}
最後兩天打算完成一個 LIFF 串到後端資料庫的範例,了解後端解析 ID Token 的流程,並透過實作展示 LIFF 與後端的完整互動過程。