Angular JWT close Tab or Browser then sign-out

Phai Panda
4 min readJun 19, 2024

--

เคยไหมที่ให้ 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 ต่อไปนี้

  1. sign-in ต้องได้ token เก็บไว้ที่ Browser
  2. sign-out ต้องลบ token ที่เก็บไว้
  3. ปิด 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 นั้นๆ
local and session storages

ตามปกติแล้วหลังจาก sign-in สำเร็จ เราต้องได้ token เก็บไว้ที่ local storage ตัวอย่างต่อไปนี้ตั้งชื่อ key ว่า auth

auth key

เพื่อบรรลุวัตถุประสงค์ของ 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 เหล่านั้นได้แล้วล่ะครับ

สวัสดีนะ

--

--