Note: see the link below for the English version of this article.
Đây là bài cuối trong loạt ba bài về xác thực một lần (SSO) sử dụng SAML với OKTA. Đường link tới những phần còn lại nằm tại đây.
Trong phần hai, chúng ta đã tìm hiểu cách tích hợp SSO sử dụng SAML vào một ứng dụng web ASP.NET Core. Trong phần lớn các trường hợp, ta nên dùng các identity provider (IDP) chuyên dụng, ví dụ như OKTA. Tuy nhiên, khi test hệ thống, đôi khi ta muốn tách dịch vụ của mình khỏi những dịch vụ của bên thứ ba. Ví dụ: ta cần test ứng dụng của mình mà không kết nối với Internet; hoặc ta chỉ muốn test phần logic chứ không quan tâm tới việc test kết nối,…
Trong những trường hợp trên, ta có thể tự giả lập IDP và dùng nó thay cho IDP thật. Sustainsys đã xây dựng sẵn một IDP như thế, các bạn có thể tham khảo đường link này, tuy nhiên project đó được viết bằng .NET Framework. Trong bài hôm nay, chúng ta sẽ tìm hiểu xem một IDP hoạt động như thế nào và tự mình xây dựng một IDP bằng .NET Core.
Toàn bộ code trong bài này đã được tải lên đường link dưới. Trong phiên bản IDP này, tôi đã bỏ bớt một số tính năng, đồng thời bổ sung tính năng cho phép người dùng đăng nhập mà không cần phải tương tác với trang đăng nhập của IDP.
https://github.com/duongntbk/StubIdpCore
Ngoài ra, các bạn cũng có thể tải Docker image của IDP này từ đường link dưới đây.
https://hub.docker.com/r/duongntbk/stubidpcore
File metadata
Khái quát về metadata
Như đã nhắc đến trong phần hai, ứng dụng web của ta sẽ chuyển tiếp người dùng tới trang đăng nhập của OKTA. Nhưng làm thế nào mà ứng dụng biết địa chỉ của OKTA là gì? Đó chính là nhờ giá trị MetadataLocation
mà ta thiết lập trong lớp ConfigurationExtensions
. Ứng dụng của ta sẽ truy cập tới URL đó để tải về một file metadata.
Vì vậy bước đầu tiên ta cần làm là tạo một trang trên IDP để cho người dùng tải file metadata. Nội dung của một file metadata như sau.
<EntityDescriptor cacheDuration="PT15M" validUntil="2021-07-02T13:08:39.1146512Z" entityID="https://localhost:5003/interactive"
xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata" ID="_a6199cf8611c4c2487276fd544d7f3f1">
<Signature
xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<Reference URI="#_a6199cf8611c4c2487276fd544d7f3f1">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<DigestValue>l7uUZhUlwtLS8V4jyUAtIGSSyCpMJFOWhV9rWRugg8s=</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>dXZuZfrb4+gUaBjvxJDBZF7I49a4cFKH1zrKPz8Dnkce6GKzYFyDfGcXfwk6IZWxkl5nlx5StdLJ9Pfv+l20WkdZVgOnBpvrw8/8lTR237bo1Cl76eayswB8H4zmmrMyUGI0VtKKL/kuMkJXR83bC5pWggUYkuDM4hmgkmclz3E=</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>[Long value omitted]</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor>
<KeyInfo
xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>[Long value omitted]</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost:5003/interactive/login" />
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost:5003/interactive/login" />
</IDPSSODescriptor>
</EntityDescriptor>
Ta cần lưu ý các điểm sau.
entityID
: đây là ID của IDP (chứ không phải ID của ứng dụng web). Nó phải khớp với giá trị của biếnIdentityProvider
trong lớpConfigurationExtensions
.X509Certificate
: đây là public key của certificate của IDP. Ứng dụng của ta sẽ dùng key này để xác nhận rằng response nó nhận được đúng là của IDP.SingleSignOnService
: đây là địa chỉ trang đăng nhập trên IDP. Ứng dụng của ta sẽ chuyển tiếp người dùng tớiHTTP-Redirect
. Còn khi người dùng đăng nhập vào tài khoản,HTTP-POST
sẽ xử lý request của họ.
Lớp MetadataHelper
Sustainsys cung cấp một hàm extension với tên gọi ToXmlString. Hàm này làm nhiệm vụ chuyển đối tượng với kiểu là MetadataBase
thành kiểu string
. Ta sẽ dùng lớp MetadataHelper để tạo đối tượng với kiểu EntityDescriptor
(là lớp con của MetadataBase
) rồi gọi hàm ToXmlString
trên EntityDescriptor
đó. Đối tượng EntityDescriptor
này sẽ chứa tất cả các thông tin cần thiết để kết nối tới IDP.
public static EntityDescriptor CreateIdpMetadata(string idpEntityId, Uri ssoServiceUrl)
Đầu tiên ta thiết lập ID cho IDP.
var metadata = new EntityDescriptor()
{
EntityId = new EntityId(idpEntityId)
};
Sau đó chúng ta cần thiết lập URL cho SSO.
idpSsoDescriptor.SingleSignOnServices.Add(new SingleSignOnService()
{
Binding = Saml2Binding.HttpRedirectUri, // đổi dòng này thành Saml2Binding.HttpPostUri để đặt URL cho for HTTP-POST
Location = ssoServiceUrl
});
Ta còn cần có certificate để chứng thực cho response.
idpSsoDescriptor.Keys.Add(CertificateHelper.SigningKey);
Key của ta được lưu trong một đối tượng KeyDescriptor
. Ta dùng lớp CertificateHelper này để tạo KeyDescriptor
. Đoạn code để đọc certificate từ thiết bị lưu trữ như sau.
var executableLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
var keyPath = Path.Combine(executableLocation, "AppData", "stubidp.sustainsys.com.pfx");
return new X509Certificate2(keyPath, "", X509KeyStorageFlags.MachineKeySet);
Ta có thể thấy rằng certificate được lưu trong AppData. Lưu ý certificate này chỉ để dùng khi test, xin đừng dùng nó trong các môi trường khác.
Sau khi đã có lớp MetadataHelper ta gọi nó và truyền vào ID cùng URL của trang login của IDP.
var content = MetadataHelper.CreateIdpMetadata(
"<Entity ID của IDP>", "<trang đăng nhập của IDP>")
.ToXmlString(CertificateHelper.SigningCertificate, SignedXml.XmlDsigRSASHA256Url);
Xử lý request đăng nhập
IDP phiên bản có tương tác
Với các IDP thông thường, thông tin đăng nhập của người dùng được kiểm tra trước khi họ được phép đăng nhập. Tuy nhiên IDP của ta sẽ không thực hiện bước này. Dưới đây là trang đăng nhập của ta, người dùng có thể nhập bất kỳ tài khoản nào mà vẫn có thể đăng nhập.
Như đã nói trong phần Metadata ở trên, IDP của ta có hai trang là HTTP-Redirect và HTTP-POST. Đầu tiên ta sẽ tìm hiểu trang HTTP-Redirect.
public ActionResult OnGetLogin()
{
Model = AssertionModel.CreateFromConfiguration();
HandleReceivedAuthenRequest();
return Page();
}
Chúng ta dùng hàm extension này để tạo model cho trang đăng nhập. Hàm này chỉ khởi tạo giá trị mặc định cho các trường AssertionConsumerServiceUrl, NameId và SessionIndex.
Như đã nói đến trong phần hai, quá trình xác thực có thể được kích hoạt bởi ứng dụng web hoặc bởi chính IDP. Cả hai phương án đó đều được xử lý trong hàm HandleReceivedAuthenRequest.
Với quá trình xác thực do ứng dụng kích hoạt, ta lấy thông tin từ request xác thực của ứng dụng và lưu vào trường tương ứng trong AssertionModel. Còn với quá trình xác thực do IDP kích hoạt, requestData
sẽ có giá trị là null
, ta sẽ hiển thị form đăng nhập với chỉ ba trường mặc định đã nhắc đến ở trên. Người dùng phải tự mình điền thông tin vào các trường còn lại.
var requestData = Request.ToHttpRequestData();
var binding = Saml2Binding.Get(requestData);
if (binding != null)
{
// code to update AssertionModel
}
Còn dưới đây là trang HTTP-POST.
public ActionResult OnPostLogin()
{
if (ModelState.IsValid)
{
// Trong IDP thật, code kiểm tra tài khoản/mật khẩu sẽ nằm ở đây
return HandleReceivedAuthenResponse(UrlResolver.InteractiveIdpEntityId);
}
if (Model == null)
{
Model = AssertionModel.CreateFromConfiguration();
}
return Page();
}
Nếu model của trang là không hợp lệ (VD: thiếu giá trị AssertionConsumerServiceUrl,…) thì ta sẽ khởi tạo lại AssertionModel và cho người dùng nhập thông tin đăng nhập lại từ đầu. Còn nếu model của trang là hợp lệ thì ta sẽ dùng hàm HandleReceivedAuthenResponse để tạo response xác thực và trả về cho người dùng.
var response = Model.ToSaml2Response(entityId);
return Saml2Binding.Get(Model.ResponseBinding)
.Bind(response).ToActionResult();
Ta dùng một hàm extension gọi là ToSaml2Response để chuyển đổi model trang sang kiểu Saml2Response
. Sau đó ta dùng hàm ToActionResult để chuyển Saml2Response
sang kiểu ActionResult
trước khi trả nó lại cho người dùng.
IDP phiên bản tự động
Đôi khi, ta muốn cho phép người dùng đăng nhập tự động mà không cần phải tương tác với IDP. Sẽ có người hỏi nếu vậy sao còn tích hợp SSO vào ứng dụng làm gì? Câu trả lời là chức năng đăng nhập tự động này có thể hữu ích khi ta chạy integration test.
Giả sử ta có một đoạn script để mở một vài trang trong ứng dụng, thực hiện vài thao tác rồi kiểm tra kết quả. Nếu ta chạy SSO với IDP phiên bản tương tác ở trên, ta sẽ phải bổ sung thêm code để nhập thông tin đăng nhập rồi nhấn nút Sign In. Nếu bản thân test của ta không phải để test xác thực thì phần code mới này sẽ làm tăng độ phức tạp một cách không cần thiết. Lúc này nếu test của ta có thể truy cập vào tất cả các trang như không hề có xác thực thì code sẽ dễ bảo trì hơn nhiều.
Vì người dùng không cần nhập tài khoản/mật mã nên ta có thể bỏ qua trang HTTP-POST. Ta chỉ quan tâm tới trang HTTP-Redirect dưới đây.
public ActionResult OnGetLogin()
{
Model = AssertionModel.CreateFromConfiguration();
HandleReceivedAuthenRequest();
return HandleReceivedAuthenResponse(UrlResolver.AutoIdpEntityId);
}
Có thể thấy rằng lúc này trang HTTP-Redirect là tổng hợp của hai trang HTTP-POST and HTTP-Redirect ở phần trước. Dưới đây là các điểm khác biệt.
- Ta gọi hàm HandleReceivedAuthenResponse ngay sau khi gọi HandleReceivedAuthenRequest để cho phép người dùng đăng nhập ngay sau khi họ được chuyển tiếp tới trang đăng nhập của IDP.
- Vì bản IDP tự động chỉ hỗ trợ đăng nhập do ứng dụng kích hoạt nên ta có thể chắc chắn rằng AssertionModel là hợp lệ và có chứa thông tin trong request đăng nhập từ ứng dụng. Vì thế ta có thể bỏ qua bước kiểm tra xem AssertionModel đã chứa đủ thông tin chưa.
Chạy thử
Chạy IDP mà ta vừa viết
Ta sẽ dùng ứng dụng được giới thiệu ở phần hai để thử nghiệm IDP.
Có hai cách chạy IDP. Cách thứ nhất là clone repo này và chạy lệnh sau.
dotnet run
IDP của ta sẽ chạy ở hai địa chỉ http://localhost:5002
và https://localhost:5003
.
Ta cũng có thể chạy IDP trong container Docker bằng lệnh sau.
docker run -dp 5002:80 duongntbk/stubidpcore:1.0
IDP của ta sẽ chạy ở địa chỉ http://localhost:5002
(bản Docker không hỗ trợ HTTPS).
Thử nghiệm IDP phiên bản tương tác
Cập nhật file appsettings.json
để bổ sung các thông tin sau.
"Authentication": {
"Saml": {
"EntityId": "https://localhost:5001/Saml2",
"IdentityProviderIssuer": "http://localhost:5002/interactive",
"MetadataUrl": "http://localhost:5002/interactive/metadata"
}
}
Người dùng có thể dùng bất kỳ tài khoản nào để đăng nhập.
Họ cũng có thể đăng nhập vào ứng dụng ngay từ IDP.
Thử IDP phiên bản tự động
Cập nhật file appsettings.json
để bổ sung các thông tin sau.
"Authentication": {
"Saml": {
"EntityId": "https://localhost:5001/Saml2",
"IdentityProviderIssuer": "http://localhost:5002/auto",
"MetadataUrl": "http://localhost:5002/auto/metadata"
}
}
Người dùng sẽ được đăng nhập ngay sau khi họ được chuyển tiếp tới IDP. Chú ý là URL của IDP của ta (cổng 5002
) được hiển thị trong một tích tắc trước khi ứng dụng web lại được hiển thị.
Kết thúc
Cách tìm hiểu SSO và SAML nhanh nhất là thử tạo và chạy một stub IDP, vì như người ta hay nói, "trăm thấy không bằng một làm". Các bạn cũng nên dùng thử StubIdp của Sustainsys, tôi tin rằng việc này sẽ giúp các bạn rút ra nhiều điều thú vị.
2 Thoughts on “Thiết lập stub identity provider để chạy test”