မင်္ဂလာပါ။
ကျွန်တော်ကတော့ Spiceworks Myanmar မှာ Backend Developer အနေနဲ့ တာဝန်ယူလုပ်ကိုင်နေတဲ့ သုတယာမိုး ဖြစ်ပါတယ်။ ဒီတစ်ခေါက်မှာတော့ How to store and send Bearer Token securely in the browser ဆိုတဲ့ ခေါင်းစဥ်နှင့်ပတ်သက်ပြီး တင်ပြပေးသွားမှာဖြစ်ပါတယ်။ user တွေရဲ့ အရေးကြီးတဲ့ data တွေကို ခွင့်ပြုချက်မရှိဘဲ ဝင်ရောက်ခြင်းမှကာကွယ်ရန် Bearer token ကို လုံခြုံစွာထားဖို့က အရေးကြီးပါတယ်။
API Authentication တစ်ခု အောင်မြင်သွားတဲ့အခါမှာ Access Token, Refresh token, Token Type နှင့် Expiration Time အစရှိတဲ့ data တွေကို ပြန်လည်ပေးပို့ပေးလေ့ရှိပါတယ်။ ထိုအထဲမှ Access Token နှင့် Refresh Token တွေကို လုံခြုံစွာသိမ်းဆည်းဖို့ လိုအပ်ပါတယ်။ ထို data များကို လုံခြုံစွာသိမ်းဆည်းနိုင်မည့် နည်းလမ်းတစ်ခုကတော့ Access Token ကို Local Storage, Cookies တို့မှာ သိမ်းတာမျိုးမဟုတ်ဘဲ Memory ထဲမှာ သိမ်းမှာဖြစ်ပါတယ်။ ထို့နောက် Refresh Token ကိုတော့ HTTP-only cookie တွင် သိမ်းဆည်မှာဖြစ်ပါတယ်။ ထိုသို့ပြုလုပ်ခြင်းကြောင့် client-side ကနေ ထို tokens တွေကို access လုပ်ခြင်းမှတားဆီးနိုင်ပြီး Cross-Site Scripting (XSS) တိုက်ခိုက်မှုများ၏အန္တရာယ်တွေကို လျှော့ချနိုင်မှာ ဖြစ်ပါတယ်။ ထို့ပြင် Access Token ရဲ့ expiration time ကိုအချိန်တိုသာထားပြီး Refresh Token ကို အသုံးပြုကာ မကြာခဏ Refresh လုပ်ခြင်းအားဖြင့် security ပိုင်း leak ဖြစ်မှုကို လျှော့ချနိုင်မှာ ဖြစ်ပါတယ်။ ယခု blog မှာတော့ API Authentication အတွက် Laravel, Laravel Passport နှင့် Vue.js တို့ကို အသုံးပြုကာ ဖော်ပြပေးသွားမှာဖြစ်ပါတယ်။
ပထမဆုံးအနေဖြင့် Laravel, Vue.js နှင့် Laravel Passport တို့ကို install လုပ်ပါမယ်။ Install လုပ်ပြီးပါက database migration ပြုလုပ်ပြီး Laravel Password Grant Client တစ်ခု create လုပ်ပါ။
//Install Laravel composer create-project laravel/laravel example-app //Install Vue.js npm create vite@latest my-vue-app -- --template vue //Install Laravel Passport composer require laravel/passport //Datebase Migration php artisan migrate //Generate Laravel Password Grant Client php artisan passport:install (or) php artisan passport:client --password
routes/api.php မှာ login, logout, refresh, me အစရှိတဲ့ routes တွေပါဝင်ပါတယ်။
<?php use App\Http\Controllers\AuthController; use Illuminate\Support\Facades\Route; Route::group(['middleware' => 'auth:api'], function() { Route::get('/me', [AuthController::class, 'user']); }); Route::post('/login', [AuthController::class, 'login']); Route::post('/logout', [AuthController::class, 'logout']); Route::post('/refresh', [AuthController::class, 'refresh']);
ထို့နောက် AuthController တစ်ခု create လုပ်ပါ။
<php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; class AuthController extends Controller { public function login(Request $request) { $response = Http::asForm()->post('http://passport-app.test/oauth/token', [ 'grant_type' => 'password', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'username' => 'taylor@laravel.com', 'password' => 'my-password', 'scope' => '', ]); if ($response->status() == 200) { $refreshToken = $response->json()['refresh_token']; $refreshCookie = cookie('refresh_token', $refreshToken, 86400, '/', null, true, true, false, 'None'); return response()->json(['token' => $response->json()['access_token']])->withCookie($refreshCookie); } if ($response->failed()) { return response()->json('Unauthorized', 401); } } public function user() { return auth()->user(); } public function refresh(Request $request) { $refresh_token = $request->cookie('refresh_token'); $response = Http::asForm()->post('http://passport-app.test/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => 'the-refresh-token', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'scope' => '', ]); if ($response->status() == 200) { $refreshToken = $response->json()['refresh_token']; $refreshCookie = cookie('refresh_token', $refreshToken, 86400, '/', null, true, true, false, 'None'); return response()->json(['token' => $response->json()['access_token']])->withCookie($refreshCookie); } if ($response->failed()) { return response()->json('Unauthorized', 401); } } }
Login Function ကတော့ client-side က ပေးပို့လာတဲ့ credentials တွေဖြစ်တဲ့ Email, Password တို့ကို မှန်မမှန်စစ်ပြီး မှန်ကန်ပါက Access Token, Refresh token, Token Type နှင့် Expiration Time အစရှိတဲ့ data တွေကို client-side ပြန်လည်ပေးပို့ပေးတဲ့ Function ဖြစ်ပါတယ်။ client-id နှင့် client-secret တို့နေရာမှာ php artisan passport:install (or) php artisan passport:client –password တို့မှ command တစ်ခုကို run လိုက်တဲ့အခါမှာရရှိတဲ့ data ကို ထည့်သွင်းပေးရမှာဖြစ်ပါတယ်။
User Function ကတော့ လက်ရှိ Authenticated user ကို ပြန်လည်ပေးပို့တဲ့ Function ဖြစ်ပါတယ်။
Refresh Function ကတော့ client-side ကနေ ပေးပို့လာတဲ့ refresh token က မှန်ကန်မှုရှိမရှိ၊ သက်တမ်းကုန်လားမကုန်လား စစ်ဆေးပြီး အောင်မြင်ပါက Access Token အသစ်၊ Refresh Token အသစ်တို့ကို ပြန်လည်ပေးပို့တဲ့ Function ဖြစ်ပါတယ်။
Frontend ပိုင်းကို ဆက်သွားကြရအောင်။ Fig.(1) မှာကတော့ vue.js folder structure ပုံစံဖြစ်ပါတယ်။ Vue.js မှာတော့ api request တွေအတွက် axios, routes တွေအတွက် vue-router နှင့် state management အတွက် pinia အစရှိတဲ့ package တွေကို အသုံးပြုမှာဖြစ်ပါတယ်။ အဆိုပါ package များကို အောက်ပါ command တို့ကိုအသုံးပြုကာ install ပြုလုပ်နိုင်ပါတယ်။
npm install axios npm install vue-router@4 npm install pinia
ပထမဦးဆုံးအနေဖြင့် Login Page တစ်ခု create လုပ်ပါမယ်။ Login Page မှာ ပါဝင်တဲ့အကြောင်းအရာ အကျဥ်းချုပ်ကတော့ user က login ဝင်တာအောင်မြင်တဲ့အခါမှာ backend ကနေပြန်လည်ပေးပို့လာတဲ့ Access Token ကို stores/auth-store.js (Authstore.token) မှာသိမ်းပြီး home page ကို redirect လုပ်ပေးခြင်း ဖြစ်ပါတယ်။ ထိုနည်းဖြင့် Access Token ကို memory ထဲမှာ သိမ်းဆည်းနိုင်မှာဖြစ်ပါတယ်။ localStorage.setItem(‘isAuthenticated’, true) ကတော့ authenticated ဖြစ်ပြီးသားဖြစ်တဲ့ user တစ်ယောက်က login page ကိုသွားတဲ့အခါမှာ သွားလို့မရအောင် တားဆီးချင်တဲ့အတွက် ထည့်သွင်းထားတာဖြစ်ပါတယ်။
#pages/LoginPage.vue <template></templategt; <script setup> import { useAuthStore } from '../store/auth-store' import { reactive } from 'vue' import { useRouter } from 'vue-router' const info = reactive({ email: 'kale19@example.com', password: 'password' }) const router = useRouter() const AuthStore = useAuthStore() const onSubmit = async(e) => { try { e.preventDefault() if (info.email && info.password) { const res = await AuthStore.login(info) localStorage.setItem('isAuthenticated', true) AuthStore.token = res.data?.token AuthStore.isAuthenticated = true router.push({name: 'home'}) } } catch (error) { console.error(error) } } </script>Sign in to your account
routes/routes.js မှာတော့ project မှာပါဝင်မယ့် route တွေကို ရေးတဲ့ file ဖြစ်ပါတယ်။ ထို file မှာပါတဲ့ requiredAuth function တွေကတော့ home page, about page တို့ကို authenticated user ကသာ ဝင်ခွင့်ရအောင် လုပ်ပေးမယ့် middleware function ဖြစ်ပြီး notRequiredAuth function တွေကတော့ authenticated user က login route သို့ သွားခွင့်ကို တားဆီးဖို့အတွက် အသုံးပြုတဲ့ middleware function ဖြစ်ပါတယ်။
#routes/routes.js import { requiredAuth, notRequiredAuth } from './guard' const routes = [ { path: '/', redirect: '/home' }, { path: '/home', name: 'home', component: () => import('../pages/Home.vue'), beforeEnter: [requiredAuth] }, { path: '/about', name: 'about', component: () => import('../pages/About.vue'), beforeEnter: [requiredAuth] }, { path: '/login', name: 'login', component: () => import('../pages/Login.vue'), beforeEnter: [notRequiredAuth] }, ] export default routes
ထို့နောက် user က page တွေကို ဝင်ရောက်တဲ့အခါမှာ authenticated ဖြစ်လား၊ မဖြစ်ဘူးလား ဆိုတာကို သိရဖို့အတွက် middleware တစ်ခုထားရှိရပါမယ်။ ကျွန်တော်ကတော့ guard.js ကို middleware အဖြစ်အသုံးပြုထားပါတယ်။ routes/guard.js မှာပါဝင်တဲ့ အကြောင်းအရာတွေကတော့ အောက်ပါအတိုင်းဖြစ်ပါတယ်။ HomePage, AboutPage တို့ကို user က ဝင်ရောက်တဲ့အခါမှာ me() ဆိုတဲ့ api request ကိုခေါ်ထားခြင်းကြောင့် ထို request အောင်မြင်မှသာ အဆိုပါ page တွေကို ဖော်ပြပေးမှာဖြစ်ပါတယ်။
#routes/guard.js import { useAuthStore } from '../store/auth-store' export const requiredAuth = async(to, from, next) => { const AuthStore = useAuthStore() try { await AuthStore.me() next() } catch(error) { throw error } } export const notRequiredAuth = async(to, from, next) => { const AuthStore = useAuthStore() if (AuthStore.isAuthenticated) next(from.path) else next() }
Access Token ကို refresh လုပ်ဖို့အတွက် လိုအပ်တဲ့လုပ်ဆောင်ပုံကတော့ အောက်ပါအတိုင်းဖြစ်ပါတယ်။ Guard.js ကနေ page တစ်ခုကိုဝင်လိုက်တိုင်းမှာ “http://127.0.0.1:8000/api/me” ဆိုတဲ့ API request ကိုခေါ်မှာဖြစ်ပါတယ်။ အဲဒီ request က မအောင်မြင်ဘဲ 401 response status ပြန်ပို့လာတဲ့အခါမှာ HTTP-only cookie ထဲမှာ ရှိနေတဲ့ refresh token ကိုအသုံးပြုပြီး access token ကို အသစ်လှမ်းခေါ်မှာဖြစ်ပါတယ်။ ထိုလုပ်ငန်းစဥ်ကို Line 22 to 38 မှာ ရေးသားထားတာဖြစ်ပါတယ်။ refresh token ကသက်တမ်းရှိနေသ၍ access token အသစ် ထုတ်ပေးနေမှာဖြစ်ပါတယ်။ refresh token သက်တမ်းကုန်တဲ့အချိန်မှသာ logout ဖြစ်သွားမှာဖြစ်ပါတယ်။
#api/axios.js import axios from 'axios' import { useAuthStore } from '../store/auth-store' let isRefreshing = false const api = axios.create({ baseURL: 'http://127.0.0.1:8000/api', contentType: 'application/json', timeout: 100000, withCredentials: true, }) api.interceptors.request.use(async (request) => { const AuthStore = useAuthStore() const token = AuthStore.getToken if (token) { request.headers['Authorization'] = `Bearer ${token}` } return request }) api.interceptors.response.use(async (response) => { return response }, async(error) => { const AuthStore = useAuthStore() if (error?.response?.status === 401 && !isRefreshing) { try { isRefreshing = true await AuthStore.refresh() return error.config } catch(e) { } finally { isRefreshing = false } } throw error }) export { api }
store/auth-store.js ကတော့ အောက်ပါအတိုင်းဖြစ်ပါတယ်။
import { defineStore } from 'pinia'; import { api } from '../api/axios' export const useAuthStore = defineStore('auth-store', { state: () => ({ token: null, isAuthenticated: localStorage.getItem('isAuthenticated') || null, expires_in: null, currentUser: null }), getters: { getToken: (state) => state.token }, actions: { async login(payload) { try { return await api.post('/login', payload) } catch(e) { throw e } }, async logout() { try { await api.post('/logout') } catch(e) { throw e } }, async refresh() { try { const res = await api.post('refresh') this.token = res.data.token this.isAuthenticated = true this.expires_in = res.data.expires_in localStorage.setItem('isAuthenticated', true) } catch(e) { if (e.response.status === 401) { this.token = null this.isAuthenticated = null this.expires_in = null localStorage.removeItem('isAuthenticated') window.location.href = '/login' } else { throw e } } }, async me(payload) { try { const res = await api.get('/me', payload) this.currentUser = res.data } catch(e) { throw e } }, }, });
အချုပ်အားဖြင့် Access Token ကို memory တွင် သိမ်းဆည်းခြင်း၊ HTTP-only cookie တွင် refresh token ကိုထားရှိခြင်းနှင့် token သက်တမ်းကို ခဏတာသာ ကန့်သတ်ခြင်းဖြင့် browser တွင် bearer token အတွက် လုံခြုံရေးကို ထိရောက်စွာ မြှင့်တင်နိုင်ပါသည်။ အခုဆိုရင်တော့ How to store and send Bearer Token securely in the browser အကြောင်းကို နားလည်သွားမယ်လို့ ထင်ပါတယ်။ အဆိုပါ https://github.com/Thuta-ctrl/secure-api လင့်ခ်ကနေလည်း လေ့လာနိုင်ပါတယ်။ အဆုံးထိဖတ်ရှုပေးတဲ့အတွက် ကျေးဇူးတင်ပါတယ် ခင်ဗျ။