Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#

Một thẻ đăng nhập, nhiều dịch vụ viết bằng nhiều ngôn ngữ: JWT là gì, cần kiểm những gì, cất ở đâu cho an toàn, kèm code đã chạy chéo giữa ba ngôn ngữ.

Phát LâmĐăng Cập nhật 6 phút đọcTrung cấp
Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#
Mục lục
  1. Thẻ đăng nhập JWT là gì
  2. Làm thế nào
  3. Cho bạn nào viết code
  4. Lỗi hay gặp
  5. Nên làm gì tiếp

Hệ thống đăng nhập viết bằng Node.js, API chạy PHP, rồi thêm một job phân tích bằng Python. Câu đầu tiên cả nhóm hỏi không phải "JWT là gì" mà là: người dùng đăng nhập ở bên này, bên kia có chịu nhận không? Có, miễn là mọi bên làm theo cùng một quy tắc. Bài này kể quy tắc đó, rồi chỉ cách cấu hình cho an toàn.

Một token đi qua bốn dịch vụ viết bằng bốn ngôn ngữ khác nhau, mỗi nơi đều xác nhận hợp lệ
Cùng một token, bốn ngôn ngữ, cùng quy tắc kiểm.

Thẻ đăng nhập JWT là gì

Hình dung chiếc vé vào cổng. Trên vé in tên bạn, vai trò của bạn, vé hết hạn lúc nào, kèm một con dấu của ban tổ chức. Bảo vệ ở cổng nào cũng kiểm được vé thật hay giả nhờ con dấu, không cần gọi về văn phòng.

JWT (JSON Web Token) chính là chiếc vé đó. Nó là một chuỗi gồm ba phần cách nhau bằng dấu chấm: phần mô tả, phần nội dung, phần con dấu (chữ ký). Hai phần đầu chỉ được đổi sang dạng chữ khác chứ không bị giấu, nên ai cầm vé cũng đọc được. Con dấu chỉ bảo đảm hai điều: nội dung không bị sửa, và vé do đúng bên giữ khoá cấp. Vì vậy đừng bỏ mật khẩu hay số thẻ vào nội dung vé.

Bốn dòng trong nội dung mà nơi nào kiểm vé cũng phải soát: hạn dùng (exp), thời điểm vé bắt đầu có hiệu lực (nbf), ai cấp vé (iss), và vé dành cho dịch vụ nào (aud). Dòng cuối hay bị bỏ quên. Nếu cả hệ thống dùng chung một khoá, vé cấp cho dịch vụ A có thể bị đem sang dịch vụ B dùng, trừ khi B chỉ nhận vé ghi tên B.

Làm thế nào

Có sáu việc nên làm, theo thứ tự.

  1. Chọn kiểu ký. Cách đơn giản là HS256, tức một bí mật dùng chung: bên cấp vé và bên kiểm vé cùng giữ một khoá. Hợp khi cả hai phía đều do bạn kiểm soát. Khoá dài ít nhất 32 byte, ngẫu nhiên.
  2. Khi cấp vé, ghi đủ iss, aud, exp, và để hạn ngắn, cỡ 15 phút.
  3. Khi kiểm vé, nói rõ với thư viện rằng chỉ nhận thuật toán HS256, và phải khớp iss, aud. Đừng để vé tự khai thuật toán rồi thư viện làm theo.
  4. Cho phép đồng hồ các máy lệch vài giây, không phải vài phút.
  5. Với web app, cất vé trong cookie HttpOnly, Secure, SameSite=Lax. Đừng cất ở localStorage: mọi đoạn JavaScript chạy trên trang, kể cả từ một thư viện bên thứ ba bị cài mã độc, đều đọc được nó. Cookie HttpOnly thì JavaScript không đọc được.
  6. Nếu API được gọi từ app điện thoại hay dịch vụ khác thì dùng header Authorization: Bearer, và phía client tự cất vé ở nơi an toàn của nền tảng.
So sánh kệ kính để hở với két sắt khoá kín khi lưu token
localStorage giống cái kệ để hở, cookie HttpOnly giống két khoá kín.

Một lưu ý: JWT thuần không thu hồi được trước hạn. Hạn càng ngắn thì rủi ro khi lộ vé càng nhỏ. Muốn đăng nhập lâu thì dùng thêm một vé làm mới (refresh token) tách riêng.

Cho bạn nào viết code

Mình ký ở Node.js, rồi kiểm ở Node.js, Python và PHP. Rồi đổi vai: ký bằng Python, ký bằng PHP, kiểm ở cả ba. Chín trên chín lần được chấp nhận, trường sub và role đọc ra giống hệt. Muốn tương thích chỉ cần ba điều: cùng bí mật và cùng cách đổi nó thành byte (UTF-8 của chuỗi, đừng để một bên giải mã base64 còn bên kia thì không), trường thời gian là số nguyên giây, và đồng hồ máy chủ đồng bộ qua NTP.

Node.js với thư viện jose:

import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET); // ít nhất 32 byte

const token = await new SignJWT({ role: 'editor' })
  .setProtectedHeader({ alg: 'HS256' })
  .setSubject('user-42').setIssuer('https://auth.example.test')
  .setAudience('https://api.example.test').setIssuedAt().setExpirationTime('15m')
  .sign(secret);

const { payload } = await jwtVerify(token, secret, {
  algorithms: ['HS256'],
  issuer: 'https://auth.example.test',
  audience: 'https://api.example.test',
  clockTolerance: 5,
});

Python với PyJWT. Tham số algorithms là bắt buộc khi giải mã, thiết kế vậy là đúng:

import jwt
claims = jwt.decode(
    token, secret, algorithms=["HS256"],
    issuer="https://auth.example.test", audience="https://api.example.test",
    leeway=5, options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub"]},
)

PHP với firebase/php-jwt. Thư viện này kiểm exp và nbf nhưng không tự kiểm iss và aud, nên bạn phải tự so:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

JWT::$leeway = 5;
$c = JWT::decode($token, new Key(getenv('JWT_SECRET'), 'HS256'));
if (($c->iss ?? null) !== 'https://auth.example.test') throw new UnexpectedValueException('bad iss');
if (!in_array('https://api.example.test', (array) ($c->aud ?? []), true)) throw new UnexpectedValueException('bad aud');

C# thì mình viết theo tài liệu của Microsoft và chưa chạy, vì máy mình không có .NET. Hai điều cần nhớ: ClockSkew mặc định là 5 phút, rất rộng, nên hạ xuống vài giây; và đặt MapInboundClaims = false để sub, role giữ nguyên tên.

var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(token, new TokenValidationParameters {
    ValidateIssuerSigningKey = true, IssuerSigningKey = key,
    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
    ValidateIssuer = true, ValidIssuer = "https://auth.example.test",
    ValidateAudience = true, ValidAudience = "https://api.example.test",
    ValidateLifetime = true, RequireExpirationTime = true,
    ClockSkew = TimeSpan.FromSeconds(5),
}, out _);

Mình cũng thử bốn vé xấu: vé khai thuật toán none (không có chữ ký), vé hết hạn, vé ghi tên dịch vụ khác, vé ký bằng khoá khác. Node, Python và PHP đều từ chối cả bốn, mỗi nơi báo một lỗi khác tên nhưng cùng nghĩa. Header cookie mình cũng chạy thử trên một máy chủ Node nhỏ và thấy đúng dạng này:

Set-Cookie: access=<jwt>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

Dùng cookie thì cần nghĩ đến CSRF (trang lạ lén gửi yêu cầu thay bạn). SameSite=Lax chặn được phần lớn, với thao tác ghi quan trọng nên thêm một mã CSRF riêng.

Lỗi hay gặp

Lỗi mình gặp nhiều nhất không nằm ở thư viện mà ở cách dùng. Có người đặt khoá là secret hoặc 123456, loại khoá bị đoán ra trong vài giây bằng từ điển. Người khác cấp vé sống 30 ngày cho tiện, rồi lúc lộ vé thì không rút lại được. Bên PHP thường quên kiểm aud vì tin rằng thư viện đã làm. Còn có chuyện nhét địa chỉ, số điện thoại vào nội dung vé vì nghĩ vé đã được mã hoá. Nó không được mã hoá.

Nên làm gì tiếp

Trước khi đưa lên chạy thật, soát lại: thuật toán bị khoá cứng ở mọi chỗ kiểm, có đủ exp, nbf, iss, aud, khoá dài và nằm ngoài mã nguồn, vé sống ngắn, vé web nằm trong cookie HttpOnly. Khi hệ thống có nhiều dịch vụ và bạn không muốn đưa bí mật ký cho tất cả, hãy chuyển sang khoá cặp, bên kiểm chỉ cầm khoá công khai. Chuyện đó mình viết ở Đổi khoá ký đăng nhập mà không làm ai bị đăng xuất. Nếu thứ bạn cần là giấu dữ liệu chứ không phải ký xác nhận, xem Mã hoá dữ liệu nhạy cảm đúng cách với AES-GCM.

Bảo mật full-stackNode.jsPHPPythonjwths256nodejsphppythoncsharpauth

Nguồn tham khảo

  1. RFC 7519 - JSON Web Token (JWT)
  2. RFC 8725 - JSON Web Token Best Current Practices
  3. RFC 7518 - JSON Web Algorithms (JWA), mục 3.2 HMAC
  4. jose (panva) - tài liệu chính thức
  5. PyJWT - tài liệu chính thức
  6. firebase/php-jwt
  7. Microsoft Learn - JwtSecurityTokenHandler và TokenValidationParameters (.NET)