Xin chào mọi người,
À thế là mình cũng đã viết 3 bài trong Viblo Mayfest 2023 rồi. Cũng sắp không biết sẽ viết gì tiếp rồi 😄 Nhưng không sao, hôm nay người bạn thân của mình đã đưa ra gợi ý cho mình để có bài viết này đây.
1. Bài toán
Chuyện là sáng nay có 1 buổi meeting online 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:
Hey Phúc, 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?
Ồ, hay đấy. Good question! Đã bao giờ các bạn nghĩ đến câu hỏi trên chưa?
2. Câu chuyện về tính năng Log out
Để 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
5. 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 log out 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 User.model.findOneAndUpdate({_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
7. 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 😇
8. Áp tờ cờ re đuýt
Bài toán đã được giải, tuy nhiên, rất có thể sẽ còn nhiều cách hay hơn những gì mình là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é.
Và cuối cùng, nếu bài viết này hữu ích, đừng quên upvote và bookmark để lưu lại nha. Cám ơn các bạn rất nhiều.
Hẹn gặp lại!!!