Authentication với JWT, lưu token ở đâu là bảo mật nhất?
Dẫn nhập
Ngót nghét cũng đã theo nghề code dạo được hơn 4 năm, cũng làm qua nhiều dự án.
Nhưng hầu hết các dự án ở công ty lúc mình join thì đều được dựng sẵn base và platform, hoặc là bê dự án phase 1 ở 1 nơi nào đó về làm tiếp các giai đoạn tiếp theo, do đó mình không có cơ hội chọt vào phần quản lý authentication và token lắm.
Ơ nói thế thì không có nghĩa là mình không làm được, mình có nghiên cứu và áp dụng vào dự án riêng, dự án freelancer, và cả làm boilerplate cho các assignment phỏng vấn nữa 😝
Chưa kể lúc lên công ty thì mình cũng muốn đưa kiến thức research, đọc được lên chém gió cùng đồng nghiệp.
Cái topic liên quan JWT này cũng được mình discuss chém gió đâu đó gần 2 năm trước với 1 người anh, nay đã qua Sing làm việc.
JWT luôn là một phương pháp phổ biến trong việc authenticate user cho các ứng dụng web. Nhưng câu hỏi đặt ra là, chúng ta nên lưu JWT token ở đâu? Ưu nhược điểm của từng nơi lưu như thế nào? Làm cách nào để chúng ta cải thiện nó?
First things first
Mình là Kiên, developer với một tay nghề thập cẩm, từ .NET tới Java, Angular, PHP, và gần đây nhất là Golang, cái gì mình cũng chọt vào 1 chút, dạo này thì focus hơn vào backend.
Again, mình là một chú developer thích viết lách, cũng đã khá lâu kể từ bài blog cuối cùng, hôm nay mình sẽ khởi động lại bằng topic “Lưu access token ở đâu thì hợp lý?”
Các phương thức authentication
Chúng ta đảo qua một chút về các phương thức xác thực trước,
-
Basic Authentication: Dùng base64 để encrypt tên người dùng và mật khẩu, và đưa nó vào header:
GET /api/example HTTP/1.1 Host: example.com Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== Accept: application/json
Phương thức này quá cũ kĩ và không bảo mật, vì bạn có thể dễ dàng decrypt lại chuỗi authorization bên trên.
-
Session & Cookie: Lưu thông tin session đăng nhập vào cookie của trình duyệt phía client, và lưu session phía memory của server. Nhược điểm chính là không scale được, cũng như không support được mobile app, vì chúng đâu có cookie 😗.
GET /api/example HTTP/1.1 Host: example.com Cookie: session_id=abc123def456; user_id=789 Accept: application/json
-
Token-based: Sử dụng token để xác thực người dùng. Thông thường, khi người dùng đăng nhập thành công, hệ thống sẽ generate và trả về 1 token, mà phổ biến nhất chính là JWT.
GET /api/example HTTP/1.1 Host: example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRmF sZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c Accept: application/json
Với phương thức session & cookie, thì dĩ nhiên trình duyệt auto nhận authentication data và lưu vào cookie rồi, vậy còn JWT chúng ta lưu ở đâu?
JWT Authentication
JWT (JSON Web Token) như tên gọi, là token được lưu dưới dạng text, có thể parse qua JSON và chứa những thông tin liên quan để authenticate.
Mổ xẻ JWT
JWT gồm 3 phần: Header, Payload và Signature.
- Header: Chứa thông tin về loại token và thuật toán mã hóa được sử dụng.
- Payload: Chứa các thông tin liên quan đến user (ví dụ như id, tên, email, quyền hạn, thời gian hết hạn token, etc.).
- Signature: Là một chuỗi được mã hóa bằng thuật toán chữ ký số (signature algorithm) và được tạo ra bằng cách kết hợp header, payload và secret key (khóa bí mật) mà chỉ server biết.
Chúng ta có thể gán thêm information cần thiết vào phần payload bên cạnh những "Claim" cơ bản được định nghĩa trong RFC 7519.
Muốn giấu thông tin khỏi JWT thì làm sao?
Bạn có thể sẽ thắc mắc là một vài thông tin như username, email có thể bị lộ từ việc decode JWT. Vì nó chỉ được Encode chứ không được Encrypt.
Để giấu thông tin khỏi JWT, bạn có thể mã hóa thông tin trước khi đưa vào JWT. Tuy nhiên, JWT thường được sử dụng để xác thực người dùng và không được mã hóa. Thay vào đó, nó được “sign” (ký) để đảm bảo tính toàn vẹn của dữ liệu. (Tham khảo bài viết về HTTP và HTTPS để tìm hiểu thêm về chữ ký nhé).
Bạn cũng có thể tham khảo thêm chi tiết tại RFC-7516 về việc mã hóa các trường trong JWT. Tuy nhiên, hãy nhớ rằng việc mã hóa trong JWT có thể làm tăng độ phức tạp và làm chậm việc xác thực người dùng.
Tham khảo về encrypt - decrypt cho ai quan tâm: https://advancedweb.hu/how-to-sign-verify-and-encrypt-jwts-in-node/
Vậy bây giờ lưu access token ở đâu?
Chúng ta có thể bỏ qua luôn session storage và cả application state, vì chúng quá mỏng manh yếu đuối, đóng tab hoặc ctrl + F5 cái là đi mất rồi.
Local Storage
Đây chính là 1 nơi nguy hiểm, bởi vì local storage có thể được truy cập từ bất kỳ script nào chạy trên cùng 1 domain (và session storage cũng thế nha).
Chúng ta gọi đó là lỗ hổng XSS, tức Cross-Site Scripting, kẻ xấu chỉ cần inject script vào target là đã có thể lấy được token.
Điểm chí mạng thứ 2 đó chính là local storage không có thời gian expire, nó sẽ còn sống mãi. Bình thường con người sống lâu thì mừng, còn token sống lâu thì lại là vấn đề 🃏
Cookie
Một cách khác chính là lưu vào cookie, mặc dù cookie vẫn có thể truy cập từ script trong cùng domain bằng cách gọi document.cookie
.
Nhưng nó vẫn an toàn hơn localstorage, tại sao?
Thứ nhất, cookie có thời gian expiration, không sống thọ như local storage.
Thứ 2, cookie có 1 flag gọi là httpOnly
. Flag này sẽ giúp ngăn chặn cookie truy cập bởi client side.
HttpOnly flag in cookie settings
Lúc này khi các bạn truy cập document.cookie
nó chỉ trả về empty mà thôi.
Khoan!
Nếu bật HttpOnly thì cookie cũng sẽ bị dính chưởng lời nguyền CSRF (Cross-Site Request Forgery)
CSRF tức là request được gửi đi sẽ không phải gửi từ domain đã đăng nhập, mà là 1 domain lạ nào đó.
Ví dụ bạn đã đăng nhập facebook, sau đó nhờ bấm vào 1 link "nóng bỏng" share hàng nào đó trên bảng tin, bạn truy cập trang giả mạo facebook-aa.com
.
Trang này sẽ inject 1 đoạn script gửi request tới facebook.com/change-password?newPassword=ABC
, request này sẽ auto được lấy cookie của facebook.com
từ browser.
Để thoát khỏi CSRF, chúng ta cần thêm 2 flag nữa, chính là SameSite=Strict
, flag này chỉ cho phép gửi cookie đi trên cùng 1 domain đã set mà thôi.
Mà để bật SameSite strict, thì chúng ta cũng phải bật luôn flag Secure
, flag này chỉ cho phép gửi cookie đi qua kết nối HTTPS. Xem thêm tại MDN - Samesite cookie flag
Nhược điểm của việc đặt same-site chính là không bật được tính năng cross-site như social login các thứ cần kết nối tới 1 service 3rd party.
Tất cả biện pháp trên cũng không đảm bảo 100% tránh được CSRF, cho nên chúng ta vẫn cần implement 1 cơ chế nào đó để tránh CSRF phía server.
Tổng kết lại 1 chút nào
LocalStorage | Cookies | |
---|---|---|
XSS | Có | Nếu set flag HttpOnly thì tránh được XSS. |
Expiration | Không | Có |
CSRF | Không | Bị dính CSRF, mặc dù nếu set SameSite=Strict thì sẽ đỡ nguy cơ nhưng vẫn phải implement cơ chế chống CSRF phía server. |
Implement với Cookie
Cách set HttpOnly phía server
Tất nhiên rồi, khi bạn bật mode HttpOnly, thì client sẽ không có quyền truy cập vào cookie, do đó chúng ta phải thực hiện việc set cookie thông qua backend.
Với nodejs, chuyện này khá dễ:
const app = express();
app.get("/", (req, res) => {
const token = jwt.sign(payload, SECRET);
res.cookie("accessToken", token, {
maxAge: 60 * 60 * 1000, // 1 hour
httpOnly: true,
secure: true,
sameSite: "strict",
});
res.send("Cookie set!");
});
Tương tự cho Go với Gin:
r := gin.Default()
// Middleware to set the access token cookie
r.Use(func(c *gin.Context) {
accessToken := http.Cookie{
Name: "accessToken",
Value: "myAccessToken",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 3600, // 1 hour
}
http.SetCookie(c.Writer, &accessToken)
c.Next()
})
Client gửi token đi thế nào?
Bạn có thể tự hỏi, làm sao client có thể lấy được token, trong khi cookie đã không còn xuất hiện trong con mắt của client script nữa?
Đáp án là header này, hãy thêm nó vào middleware gửi request phía client.
Access-Control-Allow-Credentials: true
Khi header này được set true, trình duyệt sẽ tự động gửi bất kỳ cookie nào có thuộc tính HttpOnly mà có domain là domain đang gửi request tới. (Tham khảo tại MDN)
Với axios, chúng ta có thể enable bằng cách set withCredentials: true
:
axios.get("https://api.domainA.com", { withCredentials: true });
Nhưng ở đây lại đẻ ra 1 của nợ nữa, đó chính là CORS, ví dụ bạn đăng nhập và lưu cookie ở auth.domainA.com
, nhưng lại cần gửi request tới api.domainA.com
thì dễ bị chặn lắm.
Thì chúng ta tiếp tục phải enable CORS phía server.
Enable CORS
CORS - Cross-Origin Resource Sharing, là một tính năng bảo mật của trình duyệt giúp ngăn chặn hoặc giới hạn những resource được cho phép theo origin (field này trình duyệt tự thêm vô).
Trình duyệt sẽ tự động thêm field origin vào
Flow nôm na là nếu server đánh dấu origin là được phép (thông qua header), thì trình duyệt sẽ gửi request đi, còn nếu server không cho, thì browser sẽ chặn request lại.
Do đó mà dev có thể bypass bằng 1 extension của trình duyệt 😜, tuy nhiên ai lại bắt user đi cài extension bao giờ, do đó ta phải implement nó.
Nodejs:
const cors = require("cors");
app.use(
cors({
origin: ["https://domainA.com", "https://domainB.com/"],
})
);
Như code trên chúng ta sẽ allow 2 origin được gửi request tới chính là domainA và domainB.
Không khuyến khích dùng pattern origin: *
Với Go Gin, thì có thể tham khảo lib này nhé
Chống CSRF
Kĩ thuật này khá đơn giản, với mỗi form request, chúng ta generate 1 csrf token dựa vào session hiện tại, client gán nó vào 1 hidden input, và server sẽ xử lý field này bằng 1 thuật toán nào đó, nếu hợp lệ thì cho qua.
Ví dụ code ở client:
<form action="/submit-form" method="POST">
<input type="hidden" name="_csrf" value="{{ csrfToken }}" />
<label for="username">Username:</label>
<input type="text" id="username" name="username" />
<label for="password">Password:</label>
<input type="password" id="password" name="password" />
<button type="submit">Submit</button>
</form>
Hầu hết các framework đều support CSRF rồi, xưa mình code laravel PHP thì nó support middleware có sẵn dễ như ăn bánh.
Với Go, tham khảo thư viện gin-crsf để thêm 1 middleware:
r.Use(csrf.Middleware(csrf.Options{
Secret: "kiendeptrai",
ErrorFunc: func(c *gin.Context) {
c.String(400, "CSRF token mismatch")
c.Abort()
},
}))
Tổng kết
Hoooo, mình không nghĩ là bài này lại dài tới vậy, cứ nghĩ là viết trong 1 buổi tối, mà phải tới trưa ngày hôm sau mới xong.
Hi vọng sau bài này bạn sẽ có 1 cái nhìn tổng quan về JWT authenticaion và những vấn đề bên cạnh như CORS và bảo mật các request với CSRF.
Happy coding!
FAQs
Sau khi up bài này thì có vẻ anh em dev có vài câu hỏi liên quan, mình đã trả lời trong comment (bên Viblo), và mình tổng hợp lại ở đây luôn:
- Thế muốn dùng chung cả moblie app thì sao, khi cookie không set được vào
authorization
header?
Phía mobile vẫn lưu token rồi đẩy vào authorization header bình thường. Trong middleware authentication phía backend, ta có thể quyết định là mobile thì ta sẽ check trong authorization header, ngược lại, lấy từ cookie. Tùy vào trường hợp API backend xây mới hay sử dụng lại thì chúng ta quyết định việc implement cho hợp lý. - Thế refresh token thì sao?
Refresh token vẫn lưu vào cookie vì nó vẫn là 1 token, chỉ là TTL của nó dài hơn mà thôi. Bên cạnh đó, set thêm path cho refresh token trong cookie, chỉ accept
/refresh_token
chẳng hạn. - Cookie tối đa 4KB dung lượng, có khi nào không đủ?
Nếu không quá đặc biệt thì đủ, mình đã test 1 cặp access token và refresh token với những thông tin cơ bản, chiếm đâu đó
550Bytes
, với nhiêu đó thì không tràn cookie được :D
Tham khảo thêm:
https://ironeko.com/posts/how-to-store-access-tokens-localstorage-cookies-or-httponly