Xin chào các cậu, sau đây tớ lại way back home với series thổi à nhầm to kèn 🤭
1. Bài toán
Chuyện là chủ nhật mình có 1 buổi talkshow với cậu bạn, và trong lúc nói chuyện về tech, cậu ấy hỏi mình như này:
Ey ku, nếu giờ có 1 hệ thống, mà bỗng một hôm có 1 tính năng mới ra mắt. Để sử dụng những tính năng này, toàn bộ (hoặc một phần) người dùng trên hệ thống phải đăng nhập lại để nhận Token mới. Nhưng những token cũ trước đó vẫn còn sử dụng được, nên phải huỷ những token cũ đó đi. Thế thì làm thế nào?
Mình thì mình íu biết 🙂)) đùa thôi .... ko biết thật 🤭
2. Câu chuyện về tính năng Logout
Để giải quyết bài toán trên, muốn giải quyết cho nhiều người dùng, chúng ta phải xét đến trường hợp một người dùng trước đã. Mà với trường hợp này, ta thường xây dựng tính năng Log out, vậy nó hoạt động như thế nào?
Okay, nội dung tiếp theo của cuộc trò chuyện là thế này:
- Tôi: Vậy lúc xây dựng tính năng log out, cậu làm thế nào?
- Cậu bạn: Oh dễ mà, FE xoá token ở client, sau đó chuyển hướng user về lại trang đăng nhập là xong.
- Tôi: Ơ thế không làm gì với Token của nó à?
- Cậu bạn: Không. Người dùng có dùng nó được nữa đâu. FE xoá rồi
- Tôi: Thế nếu ai đó lấy trộm được Token trước lúc người dùng Log out, rồi dùng nó cho sau này thì sao?
- Cậu bạn: Thì là lỗi của của người dùng vì đã để bị lấy trộm, chứ mình chịu 🙂
Đấy, ngang đây thì mình cá là rất nhiều người có cùng quan điểm. Mình cũng đã hỏi rất nhiều người câu hỏi tương tự, và hầu như thứ mà mọi người làm duy nhất là:
FE xoá token ở client, sau đó chuyển hướng user về lại trang đăng nhập là xong
Tuy nhiên, việc này chưa phải là giải pháp an toàn. Khi người dùng log out, chúng ta cần phải huỷ đi những token được cấp trước đó.
Thế thì giải pháp đầu tiên mà cậu bạn tôi nghĩ đến đó là cấp Token có giới hạn thời gian sử dụng cho người dùng. Chúng ta có thể cấp kèm Refresh Token để cấp lại Token mới cho người dùng khi token cũ hết hạn.
Với giải pháp này, sau khi người dùng logout, thì token cũng sẽ bị hết hạn một thời gian sau đó. Thời gian hết hạn càng ngắn thì rủi ro càng thấp. Hmm, cũng được. Nhưng liệu nó có tối ưu không khi nó cũng sẽ ảnh hưởng 1 chút đến performance khi token càng ngắn, thì lượng request để cấp token mới nó cũng càng nhiều lên. Tất nhiên, chúng ta không tính đến những ứng dụng bắt buộc cấp token ngắn hay thậm chí không có cơ chế refresh token.
3. Blacklisting
Oh, nghe ghê nhỉ? Đưa vào “danh sách đen” cơ à? Mà đưa cái gì vào?
Thật ra, nó cũng chẳng có gì quá ghê ở đây cả. Ý tưởng thì cũng đơn giản thôi. Khi người dùng log out, ta lưu lại token đó vào “Blacklisting”. Ta có thể chọn Blacklisting là 1 cơ sở dữ liệu hay đơn giản và tốt hơn thì dùng Redis.
Lúc này, khi một request được gửi kèm với một token, ngoài kiểm tra như thông thường, chúng ta sẽ thực hiện thêm một bước để kiểm tra xem Token đó có nằm trong Blacklisting hay không. Nếu có thì trả lỗi.
Như vậy là cách làm này có thể giải quyết được bài toán Log out. Tuy nhiên, một lần nữa, performance lại sẽ bị ảnh hưởng vì mỗi request ta lại phải kiểm tra trên Blacklisting.
Vậy khi ứng dụng với hàng loạt người dùng thì thế nào?
Tương tự như trên, với mỗi người dùng khi thực hiện request lên server lần đầu tiên kể từ lúc hệ thống cập nhật, thì ta sẽ lưu token đó vào Blacklist (Blacklist này khác với Blacklist cho tính năng Logout nhé), sau đó trả lỗi và người dùng sẽ thực hiện đăng nhập lại để nhận token mới hợp lệ. Việc này chỉ thực hiện một lần duy nhất với mỗi người dùng, do vậy ta cũng cần đánh dấu người dùng đó đã được cấp token mới để không lặp lại lần sau. Cứ như vậy, ta làm hết cho toàn bộ N người dùng trên hệ thống.
Với cách này, chúng ta đã giải quyết được bài toán trên. Tuy nhiên, sẽ có 2 vấn đề như sau:
Performance vẫn sẽ ảnh hưởng một chút khi người dùng thực hiện request lên server lần đầu tiên kể từ lúc hệ thống cập nhật.
Nếu người dùng có nhiều token cũ không hợp lệ, chúng ta không thể biết được đâu là token cũ, đâu là token mới để kiểm tra điều kiện vì việc đánh dấu chỉ diễn ra 1 lần
4. Thay đổi cấu trúc JWT?
Okay, có vẻ cách trên vẫn chưa thật sự đạt hiệu quả tối đa. Bây giờ, chúng ta thử nhìn lại cấu trúc của một JWT xem thế nào:
Phần này thì mình không giải thích thêm vì mình chắc là các bạn đã biết về nó rồi. Như vậy là chúng ta thấy rằng 1 JWT được cấu tạo từ 3 phần: Header, Payload và Signature. Trong 3 phần này, chúng ta thường quan tâm nhất đến Payload. Vậy nếu chúng ta tác động và khiến cho phần Payload bị vỡ, và không còn hợp lệ nữa thì sao nhỉ? 😄
Giờ giả sử chúng ta cấp 1 token dựa trên 1 payload như sau:
{"id": user._id, "email": user.email, "role": user.role}
Như vậy, lúc verify token từ request, ta cũng sẽ nhận được một JSON như trên. Bây giờ, hãy xét payload sau:
{"id": user._id, "email": user.email, "role": user.role, "version": user.version}
Các bạn có thể thấy mình đã thêm 1 trường mới là version. Trường này là một số nguyên bất kì. Ta sẽ cấp 1 giá trị mới cho trường này khi người dùng logout hoặc khi hệ thống cần huỷ token của một người dùng bất kì. Ở hàm dưới đây, mỗi lần reset, mình sẽ tăng giá trị lên 1:
// Use this when logout or when need to destroy all old tokens
const resetUserVersion = (userID, version) => {
return UserRepository.update({_id: userID}, {version})
}
Như vậy, nếu ta thay đổi giá trị của trường này, sau đó dùng để so sánh với token được cấp trước đó, nếu 2 giá trị này là khác nhau thì có nghĩa token đã không còn hợp lệ:
// verify token
const auth = (token, user) => {
const oldPayload = jwt.verify(token, process.env.JWT_SECRET)
// check if the version is valid or not
if(oldPayload.version !== user.version) {
throw new Error("Invalid Token")
}
... // continue to check other rules
}
Tất nhiên, thay vì sử dụng DB để check, các bạn có thể cần phải sử dụng cache, ELS,... để lấy thông tin user mà không làm ảnh hưởng đến performance nhé.**
Các bạn thấy đấy, với cách này:
- Có thể áp dụng cho toàn bộ người dùng hệ thống hoặc áp dụng cho 1 phần người dùng. Ta chỉ cần đổi version cho bất kì người dùng nào cần huỷ token là được.
- Không cần phải dùng Blacklist, hay nói cách khác là chúng ta không cần phải lưu token hoặc đánh dấu người dùng. Điều này đảm bảo tính stateless của JWT.
- Chủ động trong việc huỷ token, mà không phải chờ request từ người dùng.
- Dễ dàng sử dụng, không làm suy giảm performance hệ thống.
5. Tối ưu
Như vậy, chúng ta đã giải quyết được bài toán ở đầu bài đưa ra. Vậy có cách nào tối ưu hơn nữa không?
Cách mà mình sử dụng đó là thay vì trả về lỗi và yêu cầu người dùng đăng nhập lại, ta sẽ cấp luôn token mới cho người dùng. Điều này sẽ giúp tăng trải nghiệm người dùng hơn 😇
6. Áp tờ cờ re đuýt
Bài toán đã được giải, tuy nhiên đó chưa phải là cách tối ưu nhất, có thể sẽ còn nhiều cách hay hơn những gì mình làm, mình đang tham khảo thêm...
Và nếu các bạn có cách khác hay hơn, đừng ngần ngại để lại comment để chúng ta cùng học hỏi thêm nhé.
Cám ơn các bạn rất nhiều.
Hẹn gặp lại!!!