Một BFF cho nhiều tenant, và yêu cầu duy nhất đến mà không có cookie
Các ứng dụng trang đơn được dạy cách tự giữ token của chính mình. Ứng dụng chạy luồng OAuth trong trình duyệt, nhận về một access token và thường là cả một refresh token, giữ chúng ở đâu đó trong JavaScript, rồi đính kèm vào mọi lời gọi API. Cách này được ghi chép đầy đủ, là thứ mà hầu hết các hướng dẫn trình bày, và nó đặt thông tin xác thực sống lâu nhất của bạn vào đúng một nơi mà bạn không thể bảo vệ: một môi trường thực thi sẵn lòng chạy bất cứ thứ gì tìm được đường lên trang. Một lần tiêm mã thành công, một phụ thuộc bị xâm phạm ở đâu đó trong bản build của bạn, và chẳng ai cần phải lừa đảo ai nữa. Chúng đọc token rồi rời đi, và nó vẫn tiếp tục hoạt động cho đến khi hết hạn.
Mẫu backend-for-frontend đưa thông tin xác thực ra khỏi tầm với. Trình duyệt trò chuyện với một máy chủ nhỏ thuộc về ứng dụng của bạn, và máy chủ đó chính là OAuth client bí mật. Nó thực hiện việc trao đổi code, giữ access token và refresh token, và chỉ trao cho trình duyệt một cookie mờ đục. Đây là mô tả về cách chúng tôi đã xây dựng cái của mình, và về đúng phần hóa ra thật sự thú vị: khiến nó phục vụ nhiều hơn một tenant.
Rốt cuộc trình duyệt còn lại gì
Một cookie tên là __Host-agbff, được đánh dấu HttpOnly, SameSite=Lax, có phạm vi trên toàn bộ path, không có thời hạn nên nó chết cùng phiên của trình duyệt. Giá trị của nó là 256 bit ngẫu nhiên và không gì khác. Nó không phải là token, nó không giải mã ra thành thứ gì, và việc ăn cắp nó trên đường truyền là điều bạn không thể làm với một cookie __Host- qua TLS.
Mọi thứ thực chất đều nằm ở phía máy chủ, trong một bản ghi phiên bên trong một cache phân tán: access token, refresh token, id token, thời điểm access token hết hạn, và phiên thuộc về tenant nào. Bản thân việc đăng nhập là một luồng authorization code thông thường với PKCE, do một client bí mật thực hiện, client này xác thực với token endpoint bằng secret của nó. Id token được xác thực đúng cách trên đường trở về: issuer, audience, chữ ký đối chiếu với các khóa đã công bố, thời hạn sống, rồi một phép so sánh trong thời gian hằng số giữa nonce với giá trị đã cất giữ trước khi chuyển hướng. Chỉ sau tất cả những điều đó thì một phiên mới tồn tại và một cookie mới được thiết lập.
Một header chỉ được kiểm tra sự tồn tại, và không gì khác
Trình duyệt gọi BFF để lấy thông tin người dùng của chính nó và để tiếp cận API được proxy, và các lời gọi đó mang theo một header tùy chỉnh, x-authagonal-bff. Giá trị của nó không quan trọng. Sự hiện diện của nó là toàn bộ phép kiểm tra.
Điều đó trông có vẻ lười biếng nhưng thật ra không phải. Toàn bộ lớp tấn công giả mạo yêu cầu liên trang dựa vào một lần gửi form, một thẻ ảnh, hoặc một lần điều hướng mà một origin khác có thể kích hoạt trong khi cookie của bạn đi theo, và không có cái nào trong số đó có thể đặt một header tùy chỉnh. Khoảnh khắc mà JavaScript tấn công cố thêm một header như vậy, nó thôi không còn là một yêu cầu đơn giản mà trở thành một yêu cầu có preflight, thứ mà chính sách CORS của bạn từ chối. Header đó không phải là một bí mật cần đoán, nó là bằng chứng rằng yêu cầu đến từ mã chứ không phải từ markup.
Nó bắt buộc trên các lời gọi do script tạo ra và cố ý không bắt buộc trên những lời gọi vốn dĩ là điều hướng cấp cao nhất: bắt đầu một lần đăng nhập, quay về từ nhà cung cấp danh tính, đi theo một liên kết đăng xuất. Đòi hỏi một header tùy chỉnh trên một lần điều hướng của trình duyệt sẽ chỉ làm hỏng việc điều hướng.
Làm mới, đúng một lần
Mỗi yêu cầu được proxy đều có thể phát hiện ra rằng access token sắp hết hạn, và một trang bận rộn tạo ra nhiều yêu cầu cùng một lúc. Làm mới tất cả chúng một cách ngây thơ và bạn sẽ gặp một thảm họa nhỏ: nhiều lần làm mới đồng thời với một refresh token luân phiên, mỗi lần lại làm vô hiệu những lần kia, kết cục là người dùng bị đăng xuất bởi chính lưu lượng của mình.
Vì vậy việc làm mới là đơn tuyến, theo từng phiên. Yêu cầu đầu tiên đi qua sẽ chiếm một cổng được khóa theo id phiên, và tất cả những yêu cầu còn lại phải chờ. Phần tinh tế nằm ở chỗ những yêu cầu đang chờ làm gì khi đến lượt mình: chúng đọc lại phiên từ kho lưu trữ trước khi quyết định bất cứ điều gì, bởi vì yêu cầu giữ cổng đầu tiên rất có thể đã làm mới rồi, và giá trị mà chúng nắm khi xếp hàng đã cũ. Làm mới một lần, rồi mọi người đều dùng chung kết quả.
Nó cũng ý thức về việc luân phiên, theo cái nghĩa nhàm chán mà cần thiết. Nếu token endpoint trả về một refresh token mới, nó thay thế cái đang được lưu. Nếu việc làm mới thất bại với một lỗi token, phiên bị xóa và người dùng bị đăng xuất, bởi vì một refresh token đã bị từ chối thì sẽ không được chấp nhận ở lần thử tiếp theo, và thử lại chỉ là một cách chậm hơn để bị đăng xuất.
Một BFF, nhiều tenant
Một BFF đơn tenant phân giải mọi thứ từ cấu hình lúc khởi động: một authority, một client id, một secret. Phục vụ nhiều tenant từ một lần triển khai làm hỏng điều đó, bởi vì giờ đây BFF phải tìm ra một lần đăng nhập thuộc về issuer nào, và nó phải làm vậy trước khi bất kỳ phiên nào tồn tại.
Việc chọn lựa diễn ra tại login endpoint, thông qua một query parameter mà bạn được tự đặt tên, nên nó có thể là ?slug= hoặc ?org= hoặc bất cứ cái tên nào mà sản phẩm của bạn vốn đã dùng để gọi một khách hàng. Một resolver biến khóa đó thành một cấu hình tenant: authority, client id, secret, scope. Nếu nó phân giải ra không gì cả, lần đăng nhập bị từ chối ngay tại chỗ.
Sau đó khóa này phải sống sót qua một chuyến khứ hồi đến một nhà cung cấp danh tính chẳng biết gì về nó, rồi quay trở lại. Chưa có phiên nào để giữ nó, nên nó đi nhờ trong correlation cookie, chính cái cookie mã hóa sống ngắn ngày mà vì đúng lý do này đã mang sẵn PKCE verifier, state và nonce. Mười lăm phút, mỗi lần đăng nhập một cái. Trên đường về, cookie được giải mã, tenant lại được phân giải từ khóa bên trong nó, và code được đổi lấy token tại token endpoint của tenant đó. Chỉ khi đó khóa tenant mới được ghi vào phiên, nơi nó trở thành câu trả lời bền vững.
Từ thời điểm đó, mọi đường đi đều phân giải lại từ phiên chứ không phải từ bất cứ thứ gì trình duyệt nói: làm mới, đăng xuất, proxy. Và khi một tenant thôi không phân giải được nữa, vì nó đã bị xóa hoặc bị vô hiệu hóa, phiên bị hủy chứ không lặng lẽ lùi về một tenant mặc định nào đó. Một tenant không thể phân giải là một lần đăng xuất, chứ không phải một cái nhún vai.
Yêu cầu đến mà không có cookie
Back-channel logout là nơi thiết kế này chứng minh giá trị của mình. Nhà cung cấp danh tính POST một logout token thẳng đến máy chủ của bạn khi một phiên kết thúc ở nơi khác. Không cookie. Không phiên. Hoàn toàn không có trình duyệt nào tham gia. Trong một BFF đơn tenant thì điều đó chẳng có gì đáng nói, vì chỉ có một issuer duy nhất mà nó có thể đến từ đó. Trong một BFF đa tenant, bạn phải trả lời "cái này dành cho tenant nào?" trước khi có thể trả lời "liệu cái này có thật hay không?", và tài liệu duy nhất có sẵn chính là bản thân token, thứ mà bạn chưa xác minh và do đó không thể tin.
Trình tự khiến điều này an toàn xứng đáng được nêu ra một cách chính xác. Đọc issuer claim ra khỏi token chưa được xác minh, và dùng nó cho đúng một việc: chọn cấu hình của tenant nào để nạp. Rồi xác minh chữ ký của token đối chiếu với các khóa đã công bố của tenant đó và client id của tenant đó làm audience. Một issuer giả mạo chọn đúng cái tenant mà nó đang giả làm, rồi thất bại ở khâu xác minh đối chiếu với các khóa của tenant đó, bởi vì kẻ tấn công không có các khóa ấy. Chưa từng có gì được chấp nhận dựa trên sức nặng của cái claim không đáng tin. Cái claim chỉ quyết định ai được phép phán xét nó, và người phán xét là một trust anchor mà chúng ta vốn đã có sẵn.
Sau đó là những phép kiểm tra khiến một logout token đúng là một logout token: nó không được mang nonce, vì nó không xác thực bất kỳ ai; nó phải mang sự kiện back-channel logout; và nó phải định danh hoặc một phiên cụ thể hoặc một subject. Một session id giết một phiên. Một subject giết mọi phiên mà người dùng đó có, và đó chính là dạng mà chúng tôi phát ra, bởi vì "đăng xuất tôi ở mọi nơi" mới là điều người ta thật sự muốn nói khi họ bấm đăng xuất ở mọi nơi. Đó là lý do kho phiên giữ một chỉ mục theo session id và một chỉ mục khác theo subject, thay vì chỉ theo giá trị cookie.
Đơn tenant vẫn cứ nhàm chán
Tất cả những điều này đều bất động trong trường hợp thông thường. Resolver mặc định trả về cùng một cấu hình bất kể được đưa cho khóa nào, và cơ chế phân giải theo issuer của nó trả về cấu hình đó vô điều kiện, bởi vì chỉ có một cái duy nhất. Một BFF đơn tenant hành xử đúng hệt như trước khi cái mối nối này tồn tại: không có tham số tenant, không có gì thêm trong cookie, không có chế độ hỏng hóc mới nào. Bộ máy đa tenant là tùy chọn bật lên và vô hình cho đến khi bạn đặt tên cho một query parameter.
Tách việc chọn lựa khỏi việc tin cậy
Khi bạn biến một thứ gì đó thành đa tenant, bài tập đáng làm là liệt kê mọi điểm vào và hỏi điều gì định danh tenant tại từng điểm một. Các lần điều hướng của trình duyệt mang theo một cookie do bạn phát hành và kiểm soát. Các callback chuyển hướng mang theo state do bạn ký. Các callback từ máy chủ đến máy chủ chỉ mang theo những gì bên gọi chọn để gửi đi, và bên gọi có thể đang nói dối.
Điểm vào oái oăm, cái điểm chẳng có gì đáng tin trên đó, lại là điểm mà thiết kế của nó quyết định liệu cả hệ thống có vững chắc hay không, và nó thường là điểm chẳng ai nghĩ tới cho đến khi đã muộn. Mẫu thức đã đưa chúng tôi vượt qua nó có thể khái quát hóa được: tách việc chọn lựa khỏi việc tin cậy. Định tuyến dựa trên dữ liệu không đáng tin là chuyện ổn thỏa, miễn là việc định tuyến chỉ chọn xem trust anchor nào được phép đưa ra phán quyết cuối cùng, và phán quyết cuối cùng được đưa ra bởi một thứ mà kẻ tấn công không thể giả mạo.
Nếu bạn không muốn tự tay viết tất cả những thứ đó, Authagonal cung cấp sẵn BFF cho .NET và cho Node, và cổng quản trị sẽ cấp phát client bí mật mà nó cần, redirect URI, back-channel logout endpoint và tất cả, chỉ trong một cú nhấp.