Đổi khoá ký đăng nhập mà không làm ai bị đăng xuất

Cách đổi khoá ký JWT theo từng bước để người dùng đang đăng nhập không bị văng ra: khoá cặp RS256/ES256, bảng khoá JWKS và thứ tự xuất bản, ký, gỡ khoá cũ.

Phát LâmĐăng Cập nhật 6 phút đọcNâng cao
Đổi khoá ký đăng nhập mà không làm ai bị đăng xuất
Mục lục
  1. Vì sao nên dùng khoá cặp
  2. Bảng niêm yết khoá và số hiệu khoá
  3. Làm thế nào: đổi khoá bốn bước
  4. Cho bạn nào viết code
  5. Lỗi hay gặp
  6. Nên làm gì tiếp

Có lúc bạn nghi khoá ký đăng nhập đã bị lộ, hoặc đơn giản là đến kỳ phải đổi. Cách nhanh nhất là thay khoá mới rồi khởi động lại máy chủ. Sáng hôm sau, toàn bộ người dùng đang đăng nhập bị văng ra, vì mọi thẻ cũ đều không còn khớp với khoá. Với một shop đang chạy khuyến mãi thì đó là một buổi sáng rất dài. Đổi khoá được mà không ai hay biết, miễn là làm đúng thứ tự.

Dòng thời gian xoay khoá: khoá cũ mờ dần, khoá mới xuất hiện, vùng giữa cả hai cùng hoạt động
Vùng chồng lấp giữa hai khoá giúp xoay khoá mà không làm ai bị đăng xuất.

Vì sao nên dùng khoá cặp

Bài trước (Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#) dùng một bí mật chung: bên cấp thẻ và bên kiểm thẻ cầm cùng một khoá. Nhỏ thì ổn. Khi có nhiều dịch vụ, ví dụ cổng API, dịch vụ báo cáo, dịch vụ thanh toán đều cần kiểm thẻ, thì cả ba đều giữ bí mật đó. Một nơi bị lộ là ai cũng tự cấp được thẻ giả.

Cách gọn hơn là dùng một cặp khoá. Hãy nghĩ tới con dấu của công ty. Khoá riêng là con dấu thật, chỉ nằm ở nơi cấp thẻ (gọi là server A). Khoá công khai là bản mẫu dấu dán ở quầy, ai cũng lấy được để đối chiếu, nhưng không đóng dấu được. Server B chỉ giữ khoá công khai nên kiểm được thẻ mà không bao giờ làm giả được. Hai kiểu hay dùng là RS256 (khoá RSA) và ES256 (đường cong elliptic). RS256 chạy ở mọi nơi, còn ES256 cho thẻ ngắn hơn. Với cùng nội dung, mình đo thẻ RS256 dài 570 ký tự, ES256 chỉ 313.

Server A giữ khoá riêng ký token, server B chỉ giữ khoá công khai để kiểm, giữa hai bên là kệ khoá JWKS
Khoá riêng chỉ ở nơi ký; nơi kiểm chỉ cần khoá công khai.

Bảng niêm yết khoá và số hiệu khoá

Gửi tay file khoá công khai cho từng dịch vụ thì sẽ có lúc quên một nơi. Cách chuẩn là server A dựng một địa chỉ công khai (thường là /.well-known/jwks.json) liệt kê các khoá công khai đang dùng. Danh sách đó gọi là JWKS. Mỗi khoá có một số hiệu (gọi là kid), và thẻ nào cũng ghi số hiệu khoá đã ký nó. Server B nhìn số hiệu trên thẻ, tra bảng, lấy đúng khoá. Thư viện lo giúp việc tải bảng, giữ bản sao tạm và tải lại khi gặp số hiệu lạ.

Bảng có thể chứa nhiều khoá cùng lúc. Đó là mấu chốt của việc đổi khoá êm ái.

Làm thế nào: đổi khoá bốn bước

  1. Tạo khoá mới (gọi là k2), thêm vào bảng niêm yết nhưng chưa dùng để ký. Chờ cho các server B kịp tải bảng mới, tối thiểu bằng thời gian chúng giữ bản sao tạm.
  2. Cho server A chuyển sang ký bằng k2. Thẻ cũ ký bằng k1 vẫn kiểm được, vì k1 còn trong bảng.
  3. Chờ thẻ cuối cùng ký bằng k1 hết hạn. Thời gian chờ là hạn thẻ dài nhất (ví dụ 15 phút) cộng thời gian giữ bản sao tạm.
  4. Gỡ k1 khỏi bảng và huỷ khoá riêng của k1.

Mình dựng một server A phát bảng khoá qua HTTP và một server B chỉ biết địa chỉ bảng, rồi chạy đúng quy trình trên. Kết quả thật:

1) Chỉ có k1                         OK token k1
2) Xuất bản k2, vẫn ký bằng k1       OK token k1
3) Chuyển sang ký bằng k2            OK token k1 (cũ), OK token k2 (mới)
4) Gỡ k1 khỏi bảng                   OK token k2, REJECT token k1
5) Sai thứ tự: ký bằng k3 khi chưa xuất bản
                                     REJECT token k3
   Sau khi xuất bản và hết thời gian chờ   OK token k3

Dòng số 5 là lỗi mình gặp nhiều nhất: đổi sang khoá mới trước khi bảng kịp lan ra. Thẻ mới bị từ chối cho đến khi B tải lại bảng. Quy tắc nhớ gọn là xuất bản trước, ký sau.

Cho bạn nào viết code

Để thử, sinh khoá bằng openssl. Khi chạy thật, khoá riêng nên nằm trong dịch vụ quản lý khoá của cloud hoặc kho bí mật, không nằm trong repo.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa-private.pem
openssl pkey -in rsa-private.pem -pubout -out rsa-public.pem
chmod 600 rsa-private.pem

Server A ký và ghi số hiệu khoá vào header của thẻ (Node.js với jose):

import { readFileSync } from 'node:fs';
import { importPKCS8, SignJWT } from 'jose';
const priv = await importPKCS8(readFileSync('rsa-private.pem', 'utf8'), 'RS256');
const token = await new SignJWT({ role: 'editor' })
  .setProtectedHeader({ alg: 'RS256', kid: 'k2' })
  .setSubject('user-42').setIssuer('https://auth.example.test')
  .setAudience('https://api.example.test').setIssuedAt().setExpirationTime('15m')
  .sign(priv);

Server B chỉ biết địa chỉ bảng. createRemoteJWKSet giữ bản sao tạm 10 phút, và khi gặp số hiệu lạ thì tải lại, nhưng cách nhau ít nhất 30 giây để người gửi thẻ linh tinh không làm B bị dội yêu cầu:

import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://auth.example.test/.well-known/jwks.json'));
await jwtVerify(token, jwks, { algorithms: ['RS256'], issuer: ISS, audience: AUD });

Python và PHP kiểm cùng một thẻ, cùng địa chỉ bảng, chọn đúng khoá theo số hiệu: PyJWKClient(url).get_signing_key_from_jwt(token) rồi jwt.decode(...), và JWK::parseKeySet(json, 'RS256') rồi JWT::decode(...). Mình chạy chéo cả RS256 lẫn ES256, ký bằng Node, kiểm bằng Node, Python, PHP chỉ với khoá công khai: 3 trên 3 chấp nhận cho cả hai.

Với .NET, mình chưa chạy (máy không có runtime). Gói Microsoft.IdentityModel.JsonWebTokens dùng RsaSecurityKey có KeyId để ký, còn phía kiểm tra khoá theo kid từ JsonWebKeySet. Nếu dùng ASP.NET Core AddJwtBearer với Authority, middleware tự tải và làm mới bảng khoá, bạn chỉ cần chỉnh chu kỳ làm mới cho khớp lịch đổi khoá.

Lỗi hay gặp

Có một cú lừa kinh điển mà mình đã thử lại: kẻ xấu lấy khoá công khai (ai cũng có) làm bí mật HS256 để ký thẻ giả, hy vọng B dùng cùng một khoá cho cả hai kiểu. Node, PyJWT và firebase/php-jwt đều từ chối, miễn là bạn khai danh sách thuật toán cố định ở phía kiểm. Nếu thấy thư viện nào để thuật toán thẻ tự quyết, hãy đổi cách gọi.

Mấy lỗi còn lại thì gần với đời thường. Hai khoá khác nhau mà trùng số hiệu thì B lấy nhầm, nên đặt số hiệu có ngày tạo. Gỡ khoá cũ sớm quá thì người dùng còn thẻ sống bị văng. Bảng niêm yết lỡ chứa cả khoá riêng thì mất hết, nên chỉ phát phần công khai. Và đừng bao giờ lấy địa chỉ khoá từ chính thẻ (trường jku, jwk): địa chỉ bảng phải được cấu hình cứng ở phía kiểm.

Nên làm gì tiếp

Nếu bạn đang dùng khoá chung HS256 ở nhiều dịch vụ, hãy lên lịch chuyển sang khoá cặp, rồi tập đổi khoá một lần trên môi trường thử để có sẵn quy trình trước khi cần gấp. Ghi lại hạn thẻ dài nhất và thời gian giữ bản sao tạm, hai con số đó quyết định bạn phải chờ bao lâu ở bước 1 và bước 3. Để giấu dữ liệu nằm trong database, xem Mã hoá dữ liệu nhạy cảm đúng cách với AES-GCM.

Bảo mật full-stackNode.js.NETjwtrs256es256jwkskey-rotationnodejsdotnet

Nguồn tham khảo

  1. RFC 7517 - JSON Web Key (JWK)
  2. RFC 7518 - JSON Web Algorithms (RS256, ES256)
  3. RFC 8725 - JWT Best Current Practices
  4. jose - createRemoteJWKSet
  5. PyJWT - PyJWKClient
  6. firebase/php-jwt - JWK
  7. Microsoft Learn - JsonWebTokenHandler