Angular JWT close Tab or Browser then sign-out
เคยไหมที่ให้ Browser เก็บ JWT ไว้กับ local storage แล้วมี requirement ว่าตอนปิด tab หรือ Browser ให้ทำ sign-out เพื่อลบ JWT นั้นให้ด้วย
JWT หรือ Json Web Tokens เป็นวิธีการใช้ token แทนการ authentication มีกระบวนการตรวจสอบว่า token นั้นถูกแก้ไข (ระหว่างทาง) หรือไม่ หมดอายุแล้วหรือไม่ หากไม่ถูกแก้ไขและไม่หมดอายุสถานะของมันก็คือ valid สามารถใช้มันร้องขอ resources ที่ต้องการจาก resource server ได้ ตรงกันข้ามหากว่ามัน invalid ก็จะต้องกลับไปยืนยันตัวตนกับ authenticate server ใหม่อีกครั้ง
Simple to use!
JWT หรือ token นี้ผมเปรียมเสมือน คูปอง ศูนย์อาหาร ที่เราต้องเอาเงินสดไปแลก (ซื้อ) มา
- การแลกมาด้วยเงินสดนี้เรียกว่าได้ทำ authenticated แล้ว หรือก็คือ sign-in สำเร็จ
- คูปองที่ได้จะมีอายุการใช้งาน เช่น ใช้ได้ภายในวันนั้นไม่เกิน 3 ทุ่ม โดยทั่วไปแล้วมักกำหนดให้ token มีอายุ 15 นาที
- ใช้คูปองไปแลกอาหาร เรียกว่าได้ส่งคำขอ (request) ไปยัง resource server พอได้อาหารที่สั่งไปแล้วก็คือได้ resources ไปใช้
- เมื่อคูปองสิ้นอายุไขก็ต้องกลับไปแลก (ซื้อ) ใหม่
- ถ้าทำคูปองเสียหายก็ต้องกลับไปแลก (ซื้อ) ใหม่ เปรียบได้กับสถานะของมัน invalid ก็ต้องถูก resource server ปฏิเสธคำขอ (HTTP status 401)
บทความนี้จะไม่พูดถึงการต่ออายุ token หรือที่เรียกว่า refresh token ซึ่งทีมเลือกที่จะเก็บมันไว้ที่ Database
บทความนี้คงไม่เกิดขึ้นหากไม่มี requirements ต่อไปนี้
- sign-in ต้องได้ token เก็บไว้ที่ Browser
- sign-out ต้องลบ token ที่เก็บไว้
- ปิด tab หรือปิด Browser ให้ทำ sign-out อัตโนมัติ
โดยเฉพาะสองข้อสุดท้าย user มีความกลัวมากที่จะรักษา token นี้ไว้ไม่ได้ กลัวว่ามันจะถูกขโมยและถูกนำไปใช้แทนตัวเขา ก่อให้เกิดความเสียหายต่อความรับผิดชอบของเขา
โอ้คุณพระ! อายุ token ที่ 15 นาทีถ้ามันไม่ปลอดภัยก็ลดลงเหลือ 8 หรือ 5 นาทีก็ได้ไหม คำตอบ ก็ยังรู้สึกไม่ปลอดภัย
หมดกันความ Simple
เลิกบ่น ในเมื่อ front end เขียนด้วย Angular มาดูหน่อยว่า มีวิธีไหนจัดการ requirements ข้างต้นได้
จริงๆต้องบอกว่าใช้ JavaScript สักท่าหนึ่งมาช่วยนั่นแหละ
พระเอกมา!
onbeforeunload ทำงานก็ต่อเมื่อ document กำลังจะยกเลิกการโหลด
เพื่อนๆลองดูภาพ lifecycle ต่อไปนี้กัน
Web Platform Lifecycle
จากภาพจังหวะ g, h น่าสนใจ
- g คือ user ปิด foreground tab
- h คือ user ปิด background tab
ทั้งสองวิ่งเข้าสถานะ terminated ในที่สุด
ประเด็นเลยนะ ประเด็นคือ onbeforeunload มันทำตอน refresh ด้วย (F5 or command + r) นี่มันกลับเข้าสู่จังหวะไหนกันนะ ผมเดาไปทาง User re-focuses the page
ปวดสมองเลย
Local Storage and Session Storage
Web Storage Objects (ง่ายๆคือตัว Browser) มีหน่วยความจำหลายประเภทให้ใช้ตามสถานการณ์ ในที่นี้สนใจ local กับ session storages
- Local storage เก็บถาวร
- Session storage เก็บเท่ากับอายุของ tab นั้นๆ
ตามปกติแล้วหลังจาก sign-in สำเร็จ เราต้องได้ token เก็บไว้ที่ local storage ตัวอย่างต่อไปนี้ตั้งชื่อ key ว่า auth
เพื่อบรรลุวัตถุประสงค์ของ requirement ว่าด้วย “ปิด tab หรือปิด Browser ให้ทำ sign-out อัตโนมัติ”
ที่ AppComponent จึงสั่งไปว่า
@HostListener('window:beforeunload', ['$event'])
beforeCloseTabOrBrowserAndRefresh(event: Event): void {
this.localStorageService.delete(storageKey.auth)
}
- โดยที่ตัวแปร event ไม่ได้ใช้งาน
- ส่วนฟังก์ชัน delete ของ localStorageService ข้างต้นทำแค่
localStorage.removeItem(key)
Tab Stack Service
ในขณะที่กด refresh, user จะยังคงอยู่หน้าเดิมและใช้งานฟังก์ชันต่างๆได้เป็นปกติ อีกทั้ง user สามารถเปิดเว็บเดียวกันนี้ได้หลาย tab ฉะนั้นคำถามที่ผุดในหัว ณ วินาทีนี้คือ อย่าบอกนะว่า ปิด tab ไม่หมดเท่ากับยังไม่ sign-out ? คำตอบ ใช่! (โอ้ non functional requirements มาแล้ว)
แสดงว่าเมื่อ เปิด tab ต้องนับ (count) +1 และเมื่อ ปิด tab ต้อง -1
หรือจะใช้ array แทนการนับก็ได้ คือ push เท่ากับ +1 และ pop เท่ากับ -1 ก็ชัดเจนดี
เพื่อตอบ requirement ข้างต้น เราจะสร้าง service ชื่อ TabStackService ขึ้นมา มี tabId เป็น property
@Injectable({ providedIn: 'root' })
export class TabStackService {
private tabId = ''
...
ค่าของมันถูก assign เมื่อ constructor ทำงาน
constructor() {
this.tabId = /* random UUID */
}
เพราะเลือก array แทนการนับจำนวน tab
เหตุนี้สร้าง interface มารองรับ ตั้งชื่อว่า TabStack
interface TabStack {
id: string
}
เอามาใช้ใน TabStackService มีบริการ get, set, add, remove อื่ม…ขอเพิ่ม isEmpty กับ clear ให้ด้วย
private get(): TabStack[] {
const value: string | null = localStorage.getItem(storageKey.tabStack)
return value ? JSON.parse(value) as TabStack[] : []
}
private set(stacks: TabStack[]): void {
localStorage.setItem(storageKey.tabStack, JSON.stringify(stacks))
}
add(): void {
const stacks = this.get()
stacks.push({ id: this.tabId })
this.set(stacks)
}
remove(): void {
this.set(this.get().filter(d => d.id !== this.tabId))
}
isEmpty(): boolean {
return this.get().length === 0
}
clear(): void {
localStorage.removeItem(storageKey.tabStack)
}
OK Simple
ทีนี้กลับไป update ใน AppComponent เพิ่มจังหวะ add tab ที่ constructor และ remove tab ที่ onbeforeunload
constructor() {
this.tabStackService.add()
}
@HostListener('window:beforeunload', ['$event'])
beforeCloseTabOrBrowserAndRefresh(event: Event): void {
this.tabStackService.remove()
if (this.tabStackService.isEmpty()) {
this.localStorageService.delete(storageKey.auth)
}
}
ว้าว จากโค้ดข้างต้น เมื่อปิด tab จนหมดก็จะลบ token ออกไปอัตโนมัติ (auth key)
การลบ token ออกจาก Browser เสมือน user ได้ sing-out นั่นเอง
Fix Refresh Page
ที่ AppComponent เมื่อทำงาน refresh, onbeforeunload จะทำงาน หลังจากนั้น constructor จะทำงาน
เราจะต้องเก็บ token นี้ไว้ก่อนในกรณีที่เหลือแค่ tab เดียว (หรือหน้าเดียว) แต่ แต่มันก็ต้องถูกลบออกหากว่า user ได้ปิด tab สุดท้ายนี้ ดังนั้นพระเอกของเราคือ session storage ครับ
update onbeforeunload ให้ทำเงื่อนไข tab เดียว เพิ่มฟังก์ชันชื่อ refreshOnePage
@HostListener('window:beforeunload', ['$event'])
beforeCloseTabOrBrowserAndRefresh(event: Event): void {
this.tabStackService.remove()
if (this.tabStackService.isEmpty()) {
this.refreshOnePage()
this.localStorageService.delete(storageKey.auth)
}
}
ฟังก์ชัน refreshOnePage จะคัดลอกค่า auth ไปเก็บไว้ใน session storage (เดี๋ยวอธิบายในภายหลัง***)
private refreshOnePage(): void {
const auth: AuthRes | null = this.localStorageService.get(storageKey.auth)
if (auth) this.sessionStorageService.set(storageKey.auth, auth)
}
update constructor เพิ่มฟังก์ชัน refreshManyPages ให้ทำแบบเดียวกันแต่เป็นตรงกันข้าม
constructor() {
this.tabStackService.add()
this.refreshManyPages()
}
ฟังก์ชัน refreshManyPages รับผิดชอบคัดลอกค่า auth กลับไปให้ local storage เพราะการสร้าง tab หรือหน้าใหม่จำต้องใช้ค่า auth ใน local storage
private refreshManyPages(): void {
const auth: AuthRes | null = this.sessionStorageService.get(storageKey.auth)
if (auth) this.localStorageService.set(storageKey.auth, auth)
}
จากตรงนี้หยุดเพื่อสรุปกันหน่อย
- มี tab เดียว เจ้า auth จะคัดลอกไปให้ session storage
- มีหลาย tab เจ้า auth จะคัดลอกกลับไปยัง local storage
RefreshStorageServices
ทีนี้มาถึงจุดที่บอกว่าจะอธิบายให้ฟังในภายหลัง*** เมื่อคัดลอกค่า auth ไปเก็บไว้ใน session storage กระบวนการปกติในการหาค่า auth ก็ควรจะมาหาใน session storage ก่อน หากไม่พบจึงค่อยไปหาใน local storage ถูกไหม
ผมสร้างบริการชื่อ RefreshStorageServices เพื่อทำหน้าที่นี้
ขอแบบ Simple
@Injectable({ providedIn: 'root' })
export class RefreshStorageServices {
private localStorageService = inject(LocalStorageService)
private sessionStorageService = inject(SessionStorageService)
private tabStackService = inject(TabStackService)
...
หัวใจคือฟังก์ชัน get
get<T>(key: string): T | null {
const value: T | null = this.localStorageService.get<T>(key)
if (!value) return this.sessionStorageService.get<T>(key)
return value
}
แต่แถม delete ให้ด้วย นำไปวางไว้ตอนเกิด error แล้ว catch เพื่อเคลียร์ค่าได้
delete(key: string): void {
this.localStorageService.delete(key)
this.sessionStorageService.delete(key)
this.tabStackService.clear()
}
ตัวอย่างที่ผมวาง RefreshStorageServices
เช่น ถามว่า authenticated แล้วหรือยัง
getAuthenticated(): AuthRes | null {
return this.refreshStorageServices.get<AuthRes>(storageKey.auth)
}
อื้อหือ~ เท่ากับว่าตอนนี้ก็จัดการกับทุก requirements เหล่านั้นได้แล้วล่ะครับ
สวัสดีนะ