Một nhà cung cấp danh tính nói cho chúng tôi biết bạn là ai, và chúng tôi đã tin
Đăng nhập một lần có một tiền đề âm thầm: khi một người dùng đến từ một nhà cung cấp danh tính, nhà cung cấp đó đã bảo chứng cho họ rồi, nên bạn có thể bỏ qua mật khẩu và cứ thế cho họ vào. Toàn bộ ý nghĩa nằm ở chỗ tin tưởng IdP. Lỗi mà chúng tôi sắp mô tả nằm trong khoảng cách giữa "tin IdP xác thực chính người dùng của nó" và "tin bất cứ điều gì IdP nói với bạn về việc người dùng của nó là ai". Đó không phải là cùng một câu, và sự khác biệt ấy là một vụ chiếm đoạt tài khoản.
Đây là bối cảnh. Một tenant trên nền tảng của chúng tôi có thể cấu hình các kết nối liên kết: SAML đến IdP doanh nghiệp của họ, OIDC đến một cái khác. Một người dùng đăng nhập qua một trong những kết nối đó, IdP gửi lại một assertion, và máy chủ của chúng tôi phải trả lời một câu hỏi: đây là tài khoản cục bộ nào? Làm sai ánh xạ đó thì bạn sẽ có hoặc một người lạ bị khóa ngoài tài khoản của chính họ, hoặc tệ hơn nhiều, một người lạ bước vào tài khoản của người khác.
Khóa nối là một email, và một email chỉ là một khẳng định
Mã của chúng tôi trả lời "đây là tài khoản nào" theo cách hiển nhiên. Assertion mang theo một email, nên chúng tôi tra cứu người dùng theo nó: FindByEmailAsync(assertedEmail). Nếu tồn tại một tài khoản trùng khớp, người dùng liên kết quay lại được phân giải vào nó và được đăng nhập. Sạch sẽ, đơn giản, và đúng y như cách rất nhiều tích hợp SSO được viết.
Vấn đề là cái email đó thực chất là gì. Nó không phải là một sự thật mà IdP đã chứng minh. Nó là một chuỗi ký tự trong một assertion, và assertion chỉ đáng tin tới mức độ đáng tin của kết nối mà nó đến qua. Trong một thế giới đa tenant, bạn không kiểm soát mọi kết nối. Bất kỳ ai có thể dựng lên một cái (IdP SAML của riêng họ, nhà cung cấp OIDC của riêng họ) đều có thể đặt bất cứ chuỗi nào họ thích vào trường email. Vậy là một kết nối mà bạn không tin tưởng khẳng định email = [email protected], tra cứu của chúng tôi tìm ra tài khoản thật của vị CEO, và kẻ tấn công được đăng nhập với tư cách người đó. Không mật khẩu, không lừa đảo, không lỗi nào trong mật mã học. Chữ ký trên assertion hoàn toàn hợp lệ. Nó chỉ bảo chứng cho một khẳng định mà lẽ ra chúng tôi không bao giờ nên coi là danh tính.
Điều khiến người ta bất an là từng mảnh riêng lẻ đều hoạt động đúng. IdP đã xác thực chính người dùng của nó một cách chính xác. Chữ ký đã được kiểm chứng. Tài khoản tồn tại. Vụ chiếm đoạt không phải là sự thất bại của bất kỳ kiểm tra nào; nó là việc dùng sai trường làm khóa.
Vì sao kiểm tra nhiều hơn không phải là cách khắc phục
Phản ứng đầy cám dỗ là kiểm tra email gắt gao hơn. Yêu cầu email_verified. Kiểm tra tên miền. Thêm một quy tắc. Nhưng hãy để ý hình dạng của cái bẫy: kẻ tấn công kiểm soát assertion, nên bất kỳ thuộc tính nào bạn đọc ra từ assertion đều là thuộc tính mà kẻ tấn công có thể đặt. Đòi email_verified = true từ một kết nối do chính kẻ tấn công sở hữu chẳng khác nào giao trứng cho ác. Bạn không thể kiểm tra để thoát khỏi việc tin tưởng nhầm nguồn.
Email thì ổn với vai trò một tiện ích. Nó là một khóa chính tồi tệ, vì nó có tính toàn cục (cùng một địa chỉ có thể xuất hiện ở nhiều nhà cung cấp) và có thể giả mạo (nó là bất cứ điều gì kết nối khẳng định nó nói). Một khóa nối không được phép là cả hai điều đó.
Cách khắc phục là đổi khóa, không phải thêm kiểm tra
Cách khắc phục thực sự là ngừng hẳn việc nối theo email và nối theo điều duy nhất mà một kết nối không thể giả mạo cho một nhà cung cấp mà nó không sở hữu: cặp (provider, sub). sub là định danh chủ thể mà nhà cung cấp phát hành cho người dùng đó, giới hạn trong phạm vi nhà cung cấp ấy. Một người dùng quay lại được phân giải bằng cách tra cứu danh tính cục bộ trước đó đã được liên kết với (provider, sub) của kết nối này. Kết nối của kẻ tấn công có id nhà cung cấp riêng của nó; nó có thể đúc ra bất kỳ sub nào nó thích trong phạm vi không gian tên của riêng nó, nhưng nó không thể tạo ra (provider, sub) thuộc kết nối của người khác. Không gian tên chính là hào nước bảo vệ.
Email khi đó rơi xuống đúng vai trò mà lẽ ra nó luôn nên giữ: một gợi ý để liên kết, không bao giờ để phân giải, và chỉ khi nó được bảo chứng. Chúng tôi chỉ khớp một email được khẳng định với một tài khoản hiện có khi email đó được xác minh bởi một nguồn mà chúng tôi thực sự tin tưởng cho tên miền đó: email_verified từ một kết nối có tên miền nằm trong AllowedDomains của tenant. Ngoài phạm vi đó, một danh tính liên kết mới sẽ nhận tài khoản riêng của nó, không phải tài khoản của người khác.
Bài học, rút gọn lại
Khi bạn liên kết danh tính, hãy tách bạch hai câu hỏi mà bạn dễ bị cám dỗ trộn lẫn. "Nhà cung cấp này đã xác thực người dùng này chưa?" được trả lời bằng chữ ký. "Người dùng này là ai trong hệ thống của tôi?" phải được trả lời bằng một khóa mà bên khẳng định không thể giả mạo xuyên ranh giới, nghĩa là một subject riêng theo từng nhà cung cấp, chứ không phải một thuộc tính toàn cục như email. Hãy coi mọi trường trong một assertion là do kẻ tấn công kiểm soát, trừ khi nguồn cụ thể của trường đó là một nguồn mà bạn tin tưởng cho khẳng định cụ thể ấy. Email là trường mà người ta với tới đầu tiên vì nó mang tính con người và trông có vẻ duy nhất. Nó chính xác là trường sai.
Chúng tôi đã đưa thay đổi này ra như một phần của đợt rà soát bảo mật trước khi ra mắt trên chính máy chủ xác thực của mình, trước khi bất kỳ ai giao phó cho chúng tôi việc đăng nhập của họ. Đó là loại lỗi vượt qua mọi bài kiểm tra, kiểm chứng mọi chữ ký, và trao chìa khóa cho bất cứ ai hỏi xin đúng định dạng. Cách khắc phục không phải là một ổ khóa tốt hơn. Đó là nhận ra rằng chúng tôi đã đọc nhầm dòng trên tấm thẻ căn cước.
Liên kết danh tính một cách an toàn quy về việc nối theo một khóa mà bên khẳng định không thể giả mạo, và làm sai điều đó là một vụ chiếm đoạt tài khoản vượt qua mọi bài kiểm tra. Authagonal phân giải người dùng liên kết theo subject riêng của từng nhà cung cấp, không bao giờ theo email trong assertion.