ร้านค้าที่มี LINE Official Account มักอยากให้ลูกค้ากดเมนูใน LINE แล้วเช็กยอด ดูรายการ หรือส่งสลิปได้เลย ไม่ต้องโหลดแอปหรือจำรหัสผ่าน LIFF (LINE Front-end Framework) ทำเรื่องนี้ได้ เพราะเปิดเว็บของเราใน LINE พร้อมบอกได้ว่าคนที่เปิดอยู่คือใคร
ส่วนที่ต้องระวังคือ "บอกได้ว่าเป็นใคร" นี่แหละ ข้อมูลที่ได้ในเบราว์เซอร์ ใครจะแก้ก่อนส่งมาที่ server ก็ได้ โพสต์นี้เล่าวิธีที่เราใช้ในแอปลูกค้าบน LINE ของระบบร้านทอง ตั้งแต่ login ไปจนถึงผูกบัญชี LINE เข้ากับลูกค้าในฐานข้อมูล
LIFF คือเว็บของเราที่เปิดอยู่ใน LINE
เราสร้าง LIFF app ไว้ใต้ channel แบบ LINE Login ใน LINE Developers Console ใส่ endpoint URL เป็นหน้าเว็บ Laravel ของเรา แล้วได้ LIFF ID มาหนึ่งตัว พอลูกค้ากดลิงก์ https://liff.line.me/<LIFF ID> ใน LINE หน้าเว็บของเราจะเปิดขึ้นมาใน LINE
ในหน้านั้นเราโหลด SDK @line/liff แล้วเรียก liff.init() จากนั้นก็ขอข้อมูลของคนที่เปิดได้หลายแบบ
liff.getProfile()ได้ userId ชื่อ และรูปโปรไฟล์liff.getAccessToken()ได้ access token ของ LINE Loginliff.getIDToken()ได้ ID token (JWT) ใช้ได้เมื่อเปิด scopeopenidให้ LIFF app
ทั้งหมดนี้อยู่ในเบราว์เซอร์ฝั่งลูกค้า
userId ที่เบราว์เซอร์ส่งมา ใครจะปลอมก็ได้
ทางที่ดูง่ายที่สุดคือเรียก liff.getProfile() แล้ว POST userId มาที่ server ให้ server หา user จาก userId แล้ว login ให้ ทางนี้ไม่ปลอดภัย เพราะ request นั้นใครจะยิงเองก็ได้ ใส่ userId ของคนอื่นมาแทน server ก็ไม่มีทางรู้ userId ของ LINE ก็ไม่ได้เป็นความลับด้วย เพราะ webhook และระบบอื่นที่ลูกค้าเคยใช้ก็เห็นค่านี้
เอกสาร LINE เรื่อง ส่งข้อมูลผู้ใช้จาก LIFF ไปที่ server ก็เตือนไว้ตรง ๆ ว่าอย่าส่งโปรไฟล์ที่ได้จาก SDK ไปให้ server ใช้ ให้ส่ง token ไปแล้วให้ server ถาม LINE เอง
ส่ง token ให้ server ถาม LINE เอง
LINE ให้เลือกส่งได้สองแบบ
| ส่งอะไรมา | server ทำอะไรต่อ | ต้องการ scope |
|---|---|---|
access token จาก liff.getAccessToken() |
GET /oauth2/v2.1/verify เช็ก client_id กับ expires_in แล้ว GET /v2/profile เอา userId |
profile |
ID token จาก liff.getIDToken() |
POST /oauth2/v2.1/verify พร้อม client_id ได้ sub (userId), name, picture กลับมา |
openid |
รายละเอียดของทั้งสาม endpoint อยู่ใน LINE Login API reference เราเลือกแบบ access token เพราะ LIFF app มี scope profile อยู่แล้ว ไม่ต้องไปเปิด openid เพิ่ม ถ้าแอปของเราเปิด openid ไว้ จะใช้ ID token ก็ดีเหมือนกัน เรียก LINE แค่ครั้งเดียว
ที่ต้องเช็ก client_id เพราะ endpoint verify ตอบ 200 ให้ token ที่ยังใช้ได้ทุกตัว รวมถึง token ที่ออกให้ LINE Login channel ของแอปอื่นด้วย ถ้าไม่เช็ก แอปอื่นที่ลูกค้าเคย login ไว้ก็เอา token นั้นมา login เข้าระบบเราได้ client_id ต้องตรงกับ Channel ID ของ LINE Login channel ที่ LIFF app ของเราอยู่
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class LineLogin
{
/**
* @return array{userId: string, displayName: string, pictureUrl: ?string}|null
*/
public function profile(string $accessToken): ?array
{
if (! $this->issuedForThisChannel($accessToken)) {
return null;
}
$response = Http::timeout(10)
->withToken($accessToken)
->get('https://api.line.me/v2/profile');
if (! $response->successful() || blank($response->json('userId'))) {
return null;
}
return [
'userId' => (string) $response->json('userId'),
'displayName' => (string) $response->json('displayName'),
'pictureUrl' => $response->json('pictureUrl'),
];
}
private function issuedForThisChannel(string $accessToken): bool
{
$channelId = (string) config('services.line.channel_id');
if ($channelId === '') {
return false;
}
$response = Http::timeout(10)
->get('https://api.line.me/oauth2/v2.1/verify', ['access_token' => $accessToken]);
return $response->successful()
&& (string) $response->json('client_id') === $channelId
&& (int) $response->json('expires_in') > 0;
}
}
'line' => [
'liff_id' => env('LIFF_ID'),
'channel_id' => env('LIFF_CHANNEL_ID'),
],
ถ้าลืมตั้ง LIFF_CHANNEL_ID ใน .env เมธอดนี้จะไม่ยอมให้ใคร login เลย ดีกว่าเผลอข้ามการเช็กไปเงียบ ๆ
ฝั่งหน้าเว็บก็เหลือแค่ส่ง token มา เราส่ง CSRF token จาก meta tag ไปใน header ด้วย ไม่ต้องยกเว้น route ของ LIFF ออกจาก CSRF
import liff from '@line/liff'
const liffId = document.querySelector('meta[name=liff-id]').content
const csrfToken = document.querySelector('meta[name=csrf-token]').content
await liff.init({ liffId, withLoginOnExternalBrowser: true })
const response = await fetch('/liff/verify-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ access_token: liff.getAccessToken() }),
})
if (!response.ok) {
throw new Error(`เข้าสู่ระบบด้วย LINE ไม่สำเร็จ (${response.status})`)
}
const { registered } = await response.json()
window.location.replace(registered ? '/liff/account' : '/liff/register')
withLoginOnExternalBrowser: true ทำให้ตอนเปิดลิงก์ในเบราว์เซอร์ปกติ ไม่ได้เปิดใน LINE SDK จะพาไปหน้า login ของ LINE ให้เอง
ลูกค้า LINE ใช้ guard แยกจากแอดมิน
ลูกค้าที่เข้ามาทาง LINE ไม่มีอีเมลหรือรหัสผ่าน และไม่ควรปนกับตาราง users ของแอดมิน เราจึงเก็บไว้ในตาราง line_users (userId ของ LINE, ชื่อ, รูป และ customer_id ที่ผูกไว้) แล้วเพิ่ม guard ชื่อ line ให้ใช้ session เหมือน guard web แต่ดึง user จาก model LineUser
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'line' => [
'driver' => 'session',
'provider' => 'line-users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
'line-users' => [
'driver' => 'eloquent',
'model' => App\Models\LineUser::class,
],
],
use Illuminate\Foundation\Auth\User as Authenticatable;
class LineUser extends Authenticatable
{
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}
ทีนี้ controller ก็แค่เอา token ไปถาม LineLogin ถ้าได้โปรไฟล์กลับมา ก็สร้างหรืออัปเดต LineUser จาก userId ที่ LINE ตอบ แล้ว login เข้า guard line
public function verifyToken(VerifyLineTokenRequest $request, LineLogin $lineLogin): JsonResponse
{
$profile = $lineLogin->profile($request->accessToken());
abort_if($profile === null, 401);
$user = LineUser::query()->updateOrCreate(
['line_internal_id' => $profile['userId']],
['line_id' => $profile['displayName'], 'avatar' => $profile['pictureUrl']],
);
Auth::guard('line')->login($user);
$request->session()->regenerate();
return response()->json(['registered' => $user->customer_id !== null]);
}
Route::post('/liff/verify-token', [LiffController::class, 'verifyToken'])
->middleware('throttle:liff-login')
->name('liff.verify-token');
Route::middleware('auth:line')->group(function () {
// หน้าของลูกค้า
});
RateLimiter::for('liff-login', fn (Request $request) => Limit::perMinute(30)->by($request->ip()));
มีสามจุดในโค้ดชุดนี้ที่ตั้งใจทำ
- ตอบแค่
registeredไม่ส่งข้อมูลลูกค้ากลับไปใน JSON หน้าเว็บต้องการรู้แค่ว่าจะพาไปหน้าไหนต่อ session()->regenerate()เปลี่ยน session ID หลัง login กันคนที่แอบรู้ session ID เดิมไว้ก่อน- rate limit 30 ครั้งต่อนาทีต่อ IP ตั้งไว้หลวมหน่อย เพราะลูกค้าที่ใช้เน็ตมือถือค่ายเดียวกันมักออกเน็ตผ่าน IP ชุดเดียวกัน
หลัง login แล้วเราไม่เก็บ access token ไว้ใช้ต่อ LINE เพิกถอน token นี้ทันทีที่ลูกค้าปิดหน้า LIFF ถึงจะยังไม่หมดอายุก็ตาม เราจึงเช็กกับ LINE ครั้งเดียวตอนเข้า แล้วให้ session ของ Laravel ดูแลต่อ
ถ้าลูกค้าเข้าหน้าที่ต้อง login โดยยังไม่มี session auth:line จะพาไป route login ของแอดมิน ให้บอก Laravel ว่า path ของ LIFF ต้องกลับไปหน้าเริ่มของ LIFF แทน
->withMiddleware(function (Middleware $middleware): void {
$middleware->redirectGuestsTo(fn (Request $request) => $request->is('liff/*')
? route('liff.index')
: route('login'));
})
รูปโปรไฟล์ก็ยังต้องเช็กว่ามาจาก LINE
ถ้าเราดาวน์โหลดรูปโปรไฟล์มาเก็บฝั่ง server (เช่น addMediaFromUrl() ของ medialibrary) อย่าโหลดทุก URL ที่ได้มา ให้รับเฉพาะ https://profile.line-scdn.net เท่านั้น ถ้าวันหนึ่งมีโค้ดส่วนอื่นเขียนค่าลงคอลัมน์ avatar เอง server ของเราก็จะไม่ไปโหลด URL ภายในอย่าง http://169.254.169.254/ ตามที่ใส่มา (ช่องโหว่แบบนี้เรียกว่า SSRF)
public function hasLineAvatar(): bool
{
return is_string($this->avatar)
&& parse_url($this->avatar, PHP_URL_SCHEME) === 'https'
&& parse_url($this->avatar, PHP_URL_HOST) === 'profile.line-scdn.net';
}
ผูกบัญชี LINE กับลูกค้าด้วยเบอร์โทรและรหัสจากร้าน
login ได้แล้วก็รู้แค่ว่าเป็น LINE ของใคร ยังไม่รู้ว่าคนนี้คือลูกค้าคนไหนในระบบ ครั้งแรกที่เข้ามา customer_id ของ LineUser ยังว่าง เราใส่ middleware ไว้หน้าทุกหน้าของลูกค้า ให้พาไปหน้าลงทะเบียนก่อน
public function handle(Request $request, Closure $next): Response
{
if ($request->user('line')->customer_id === null) {
return redirect()->route('liff.register');
}
return $next($request);
}
ผูกด้วยเบอร์โทรสะดวกสุด เพราะร้านมีเบอร์ลูกค้าทุกคนอยู่แล้ว แต่เบอร์โทรไม่ใช่ความลับ คนรู้จัก พนักงาน หรือใครที่เคยเห็นใบเสร็จก็รู้ได้ ถ้าหน้าลงทะเบียนเชื่อเบอร์อย่างเดียว ใครที่รู้เบอร์ก็ผูก LINE ตัวเองเข้ากับบัญชีลูกค้าคนอื่นแล้วเปิดดูยอดของเขาได้ ต้องมีอะไรอีกอย่างที่มีแต่ลูกค้าตัวจริงรู้
ทางที่เราเลือกคือให้ร้านออกรหัส 6 หลักให้ตอนลูกค้าขอผูกบัญชี ลูกค้าอยู่หน้าร้านก็บอกรหัสได้เลย หรือร้านส่งทาง SMS ก็ได้ รหัสใช้ได้ครั้งเดียวภายใน 15 นาที เราเก็บไว้ใน cache เป็น hash เหมือนรหัสผ่าน
public function __invoke(Customer $customer): string
{
$code = (string) random_int(100000, 999999);
Cache::put("line-link:{$customer->id}", Hash::make($code), now()->addMinutes(15));
return $code;
}
ฝั่งหน้าลงทะเบียนรับเบอร์กับรหัส แล้วเช็กสามอย่าง: เบอร์ตรงกับลูกค้า รหัสถูก และลูกค้าคนนั้นยังไม่ได้ผูกกับ LINE อื่น ถ้าผูกไว้แล้ว เราไม่ทับให้เงียบ ๆ เพราะลูกค้าตัวจริงจะเข้าบัญชีตัวเองไม่ได้โดยไม่รู้สาเหตุ ให้ติดต่อร้านเพื่อยกเลิกก่อน
public function store(LinkLineRequest $request): RedirectResponse
{
$customer = Customer::query()->where('tel', $request->phone())->first();
$hash = $customer ? Cache::get("line-link:{$customer->id}") : null;
if ($hash === null || ! Hash::check($request->code(), $hash)) {
return back()->withErrors(['code' => 'เบอร์โทรหรือรหัสไม่ถูกต้อง']);
}
if ($customer->line()->exists()) {
return back()->withErrors(['phone' => 'บัญชีนี้ผูกกับ LINE อื่นอยู่แล้ว ติดต่อร้านเพื่อยกเลิกก่อน']);
}
Cache::forget("line-link:{$customer->id}");
$request->user('line')->update(['customer_id' => $customer->id]);
return to_route('liff.account');
}
Route::middleware('auth:line')->group(function () {
Route::get('/liff/register', [RegisterController::class, 'index'])->name('liff.register');
Route::post('/liff/register', [RegisterController::class, 'store'])
->middleware('throttle:liff-register')
->name('liff.register.store');
Route::middleware(EnsureLineLinked::class)->group(function () {
Route::get('/liff/account', [AccountController::class, 'index'])->name('liff.account');
});
});
RateLimiter::for('liff-register', fn (Request $request) => Limit::perMinute(5)->by($request->user('line')->id));
หน้าลงทะเบียนอยู่ใต้ auth:line ด้วย คนที่ยังไม่ผ่าน LINE จะยิงเข้ามาตรง ๆ ไม่ได้ และจำกัดไว้ 5 ครั้งต่อนาทีต่อบัญชี LINE คนที่สุ่มรหัส 6 หลักจะลองได้ไม่ถึงร้อยครั้งก่อนรหัสหมดอายุ จากรหัสที่เป็นไปได้เกือบล้านแบบ
ถ้าไม่อยากให้พนักงานต้องออกรหัส ก็เปลี่ยนเป็นส่ง OTP ทาง SMS ไปที่เบอร์นั้นแทนได้ หลักเดียวกันคือเบอร์บอกว่าเป็นบัญชีไหน ส่วนรหัสพิสูจน์ว่าคนกรอกถือเบอร์นั้นอยู่จริง
แอดมินเปิดดูแอปในมุมของลูกค้าได้
เวลาลูกค้าโทรมาถามว่า "ทำไมยอดในแอปไม่ตรง" พนักงานควรเห็นหน้าจอแบบเดียวกับที่ลูกค้าเห็น เพราะเราแยก guard ไว้แล้ว แอดมินที่ login อยู่ใน guard web จึง login เข้า guard line เป็นลูกค้าคนนั้นเพิ่มได้ โดย session ของแอดมินยังอยู่ครบ
public function impersonate(Customer $customer): RedirectResponse
{
Gate::authorize('impersonate', $customer);
abort_if($customer->line === null, 404);
Auth::guard('line')->login($customer->line);
return to_route('liff.account');
}
Route::get('/liff/admin/{customer}/impersonate', [LiffController::class, 'impersonate'])
->middleware('auth')
->name('liff.admin.impersonate');
route นี้อยู่หลัง auth ของแอดมิน แล้วพาไปหน้า liff.account ตรง ๆ ซึ่งเป็นหน้า Blade ธรรมดา ไม่ได้เรียก liff.init() แอดมินจึงเปิดในเบราว์เซอร์บนคอมได้ ไม่ต้องเข้าผ่าน LINE ส่วนจะให้แอดมินคนไหนกดได้ ให้ policy เป็นคนตัดสิน และควรเก็บ log ไว้ด้วยว่าใครเปิดดูบัญชีไหนเมื่อไหร่
ทดสอบโดยไม่ต้องเรียก LINE จริง
ใน test เราไม่อยากยิงไปที่ LINE จริง Http::preventStrayRequests() จะทำให้ test พังทันทีถ้ามี request ไหนหลุดออกไปโดยไม่ได้ fake ไว้ แล้วเรา fake คำตอบของ verify กับ profile เป็นแต่ละกรณี
use App\Models\LineUser;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['services.line.channel_id' => '1650000000']);
Http::preventStrayRequests();
});
test('logs in the user LINE vouches for, ignoring identity in the body', function () {
Http::fake([
'api.line.me/oauth2/v2.1/verify*' => Http::response(['client_id' => '1650000000', 'expires_in' => 2591659]),
'api.line.me/v2/profile' => Http::response(['userId' => 'U0001', 'displayName' => 'ลูกค้า ก']),
]);
$this->postJson(route('liff.verify-token'), [
'access_token' => 'valid-token',
'userId' => 'U9999',
])->assertOk();
$this->assertAuthenticatedAs(LineUser::query()->where('line_internal_id', 'U0001')->sole(), 'line');
});
test('a token issued to another channel does not log in', function () {
Http::fake([
'api.line.me/oauth2/v2.1/verify*' => Http::response(['client_id' => '1999999999', 'expires_in' => 100]),
]);
$this->postJson(route('liff.verify-token'), ['access_token' => 'foreign-token'])
->assertUnauthorized();
$this->assertGuest('line');
});
test แรกส่ง userId ปลอมมาใน body ด้วย แล้วเช็กว่าคนที่ได้ login คือ U0001 ที่ LINE ตอบ ไม่ใช่ค่าใน body ควรเพิ่มอีกสองสามกรณีด้วย คือ token ที่ LINE ตอบ 400 กลับมา ตอนที่ไม่ได้ตั้ง LIFF_CHANNEL_ID และหน้าลงทะเบียนที่ใส่รหัสผิดหรือลูกค้าผูก LINE อื่นไว้แล้ว
ผูกแล้วก็ส่งข้อความหาลูกค้าได้
พอลูกค้าผูกบัญชีแล้ว เราก็ push ข้อความหาลูกค้าคนนั้นผ่าน Messaging API ได้ เช่นแจ้งว่าร้านได้รับสลิปแล้ว วิธีส่งข้อความเข้า LINE จาก Laravel อยู่ใน LINE Notify ปิดแล้ว ส่งแจ้งเตือนเข้า LINE จาก Laravel ใช้อะไรแทน





