使用 NET Core 建置 SAML2 SSO 站台 - 整合應用程式(SP)

了解了的 SAML 的基本概念 和 完成了 Okta 身分驗證服務商設定,本文將記錄如何將 SAML 2.0 登入功能整合到您的 Web 應用程式中,常見的 Web 網站分為傳統的 WebForm(即每次點擊會刷新整個頁面)和 SPA(單頁應用程式)搭配 API 的方式。

因為 SPA 牽涉到的範圍比較複雜,後續有機會我會在這篇文章中補充相關內容,在這篇文章中,我將使用 .NET Core 8 MVC 框架 ,並使用 ITfoxtec.Identity.Saml2、ITfoxtec.Identity.Saml2.MvcCore 這兩個套件來為來為網站加上 SAML 2.0 的功能。


前置準備

SAML 基本概念:確認您已了解 SAML(Security Assertion Markup Language)的基本原理及其運作方式。

Okta 設定:確認您已在 Okta 身分驗證服務商中完成設定,並獲取必要的 SAML metadata。


開始整合 SAML 2.0 登入

第一步:安裝必要套件 

在你的 .NET Core 8 MVC 專案中,安裝以下兩個套件

dotnet add package ITfoxtec.Identity.Saml2
dotnet add package ITfoxtec.Identity.Saml2.MvcCore


第二步:配置 SAML 2.0 參數設定

在 appsettings.json 中添加 SAML 相關的設定

  • IdPMetadata:上一篇文章中提到的 Saml Metadata URL。
  • Issuer:這個要跟 Okta 中設定的是一樣的。
  • SigningCertificate:你的自簽憑證。
  • CertificateValidationMode:因為是自簽憑證而且沒有設定根憑證,如果你的環境跟我一樣,請記得把這個設定成 None。

{ 
  "Saml2": {
    "IdPMetadata": "https://dev-20929735.okta.com/app/exkh4j3be2SlTg3135d7/sso/saml/metadata",
    "Issuer": "LawrenceService",
    "SigningCertificateFile": "server.pfx",
    "SigningCertificatePassword": "123456",
    "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
    "CertificateValidationMode": "None",
    "RevocationMode": "NoCheck"
  }
}


第三步:設置 SAML 服務

在 Startup.cs or Program.cs 中設定 SAML 服務,並且記得在 middleware 中補上 app.UseSaml2();

private static void AddAuthService(WebApplicationBuilder builder)
{
    IConfiguration configuration = builder.Configuration;
    IWebHostEnvironment environment = builder.Environment;
             
    builder.Services
        .Configure(configuration.GetSection("Saml2"))
        .Configure(saml2Configuration =>
        {
            // 配置 SAML2 服務提供者(SP)
            var spCertFile = configuration["Saml2:SigningCertificateFile"]; 
            var spCertPassword = configuration["Saml2:SigningCertificatePassword"];
            var spCert = CertificateUtil.Load(spCertFile, spCertPassword);
            saml2Configuration.SigningCertificate = spCert;
            saml2Configuration.AllowedAudienceUris.Add(saml2Configuration.Issuer);


            // 配置 SAML2 身份提供者(IdP)
            var entityDescriptor = new EntityDescriptor();
            // 取得 IdP 的 Metadata
            entityDescriptor.ReadIdPSsoDescriptorFromUrl(new Uri(configuration["Saml2:IdPMetadata"])); 

            if (entityDescriptor.IdPSsoDescriptor != null)
            {
                var idpSsoDescriptor = entityDescriptor.IdPSsoDescriptor;
                // Idp 的登入網址
                var singleSignOnService = idpSsoDescriptor.SingleSignOnServices.First().Location; 
                // Idp 的登出網址
                var singleLogoutService = idpSsoDescriptor.SingleLogoutServices.FirstOrDefault()?.Location; 
                // Idp 的憑證
                var signingCertificates = idpSsoDescriptor.SigningCertificates;  

                saml2Configuration.SingleSignOnDestination = singleSignOnService;
                saml2Configuration.SingleLogoutDestination = singleLogoutService;
                saml2Configuration.SignatureValidationCertificates.AddRange(signingCertificates);
            }
            else
            {
                throw new Exception("IdPSsoDescriptor not loaded from metadata.");
            }
        })
        .AddSaml2(loginPath: "/SamlAuth/Login"); // 需授權頁面,但未登入時呼叫的 API
}


第四步:建立 SAML 控制器

在 Controllers 資料夾中新增 SamlAuthController,共會有四個API,因為這邊是最重要的,我將針對各 API 單獨進行說明。


1. 應用程式(SP)請求 Okta(IdP)登入的 API

這個 API 由應用程式自行指定,其主要功能是生成一個登入的 XML 請求,隨後將瀏覽器重新轉址到 SAML Metadata URL 中設定的 SSO 網址,同時也會將產生的 SAML Auth Request 一併傳送給 Okta。當 Okta 驗證請求為合法後,將會顯示登入頁面。

此外,使用 ITfoxtec.Identity.Saml2 套件來實作時,如果用戶進入需要授權的頁面,該套件會取得當前網址作為 returnUrl 參數,並執行先前設定的登入 API,而 returnUrl 參數的目的是在用戶成功登入後,將其導回到登入前的功能頁面。
[HttpGet("Login")]
public IActionResult Login(string? returnUrl = null)
{ 
    var binding = new Saml2RedirectBinding();
    binding.SetRelayStateQuery(new Dictionary { { relayStateReturnUrl, returnUrl ?? Url.Content("~/") } });

    var result = binding.Bind(new Saml2AuthnRequest(config)).ToActionResult(); 

    return result;
}

產生的 SAML Auth Request 的參考格式如下。

<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
		xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
		ID="_5e018ff0-9946-4d6a-ad1a-ce803683a3d0" 
		Version="2.0"
		IssueInstant="2024-05-23T09:28:54.158Z"
		Destination="https://dev-20929735.okta.com/app/dev-20929735_mydemo_2/exkh4j3be2SlTg3135d7/sso/saml">
	<saml:Issuer>LawrenceService</saml:Issuer>
</samlp:AuthnRequest>


2. Okta(IdP)登入成功後回應 Assertion 給應用程式(SP)的 API

當使用者成功通過 Okta 的登入驗證後,Okta 會根據應用程式中設定的 Single sign-on URL ,透過 HTTP POST 將驗證成功的相關資訊(Auth Response)傳送給該 API,當收到 Okta 回應的資訊後,你應進行權限檢查,例如檢查請求是否被竄改,強烈建議一定要進行這樣的檢查,否則可能會出現嚴重的資安漏洞
[Route("AssertionConsumerService")]
public async Task AssertionConsumerService()
{
    try
    {
        var httpRequest = Request.ToGenericHttpRequest(validate: true);

        // Unbind 方法會根據 config 中的設定,驗證 SAMLResponse 是否正確
        // 當然也可以自行驗證,例如驗證 Issuer、Audience、時間戳等
        // 強烈建議要驗證,以避免被惡意攻擊
        var saml2AuthnResponse = new Saml2AuthnResponse(config);
        httpRequest.Binding.Unbind(httpRequest, saml2AuthnResponse);

        // ClaimsTransform.Transform 是自訂的 Claims 轉換方法,可以將取得的 Claims 轉換為自己需要的格式
        // 詳細請參考 ClaimsTransform.cs
        await saml2AuthnResponse.CreateSession(HttpContext, claimsTransform: (claimsPrincipal) => ClaimsTransform.Transform(claimsPrincipal));

        // 如果直接由 IdP 進入,可能會應為缺少 ReturnUrl 而導致錯誤,這邊預設導向首頁
        var returnUrl = httpRequest.Binding.GetRelayStateQuery()[relayStateReturnUrl];
        return Redirect(string.IsNullOrWhiteSpace(returnUrl) ? Url.Content("~/") : returnUrl);
    }
    catch (Exception ex)
    {
        return Unauthorized();
    }
}
回應的 SAML Auth Response 內容有點多,可以自行參考範例程式的 Example/Saml2AuthnResponse.xml


3. 應用程式(SP)發出登出請求給 Okta(IdP)的 API

單點登出功能,此項目非必要,但如果你希望在應用程式站點登出時,同時登出 Okta(這樣下次登入時需要重新輸入帳號密碼),則必須實作這段程式碼。其邏輯與登入流程相似,會生成一個 Logout Request XML 並傳送給 Okta。

[HttpGet("Logout")]
public async Task Logout()
{
    // 如果使用者未登入,直接導向首頁(或登入頁)
    if (User.Identity == null || !User.Identity.IsAuthenticated)
        return Redirect(Url.Content("~/"));
             
    var binding = new Saml2RedirectBinding();

    // 產生 Logout Request,並刪除應用程式登入的 Session
    var saml2LogoutRequest = await new Saml2LogoutRequest(config, User).DeleteSession(HttpContext);

    // 將 SAMLRequest 綁定到 RedirectBinding,並將其轉換為 ActionResult
    var result = binding.Bind(saml2LogoutRequest).ToActionResult();

    return result;
}

產生的 SAML Auth Request 的參考格式如下。

<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" 
		xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" 
		ID="_6a485998-c361-439c-b8c7-e983514a6239" 
		Version="2.0" 
		IssueInstant="2024-05-22T08:48:10.597Z" 
		Destination="https://dev-20929735.okta.com/app/dev-20929735_mydemo_2/exkh4j3be2SlTg3135d7/slo/saml" 
		NotOnOrAfter="2024-05-22T08:58:10.597Z">
	<saml:Issuer>LawrenceService</saml:Issuer>
	<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">lawrence</saml:NameID>
	<samlp:SessionIndex>_7b6ec99a-ee2a-44e7-a9ba-be664ef35313</samlp:SessionIndex>
</samlp:LogoutRequest>

4. Okta(IdP)登出成功後回應給應用程式(SP)的 API

當使用者成功登出 Okta 後,Okta 會根據應用程式中設定的 Single Logout URL,透過 HTTP POST 將登出成功的相關資訊(Logout Response)傳送給該 API,不過這邊其實已經完成 Okta 登出作業了,實際上是否驗證已經沒有關係了。

[HttpPost("Logout")]
public IActionResult LogoutCallback()
{ 
    var httpRequest = Request.ToGenericHttpRequest(validate: true);

    // Unbind 方法會根據 config 中的設定,驗證 SAMLResponse 是否正確
    // 當然也可以自行驗證,例如驗證 Issuer、Audience、時間戳等
    // 不過這邊其實已經登出了,再一次的驗證只是要確認 Okta 端是否有成功登出而已
    var saml2LogoutResponse = new Saml2LogoutResponse(config);
    httpRequest.Binding.Unbind(httpRequest, saml2LogoutResponse);

    if (saml2LogoutResponse.Status != Saml2StatusCodes.Success)
        throw new Exception("Logout failed : " + saml2LogoutResponse.Status);

    return Redirect(Url.Content("~/"));
}

回應的 SAML Logout Response 內容有點多,可以自行參考範例程式的 Example/Saml2LogoutResponse.xml


功能展示

下面的影片展示了這四個 API 的操作功能,提供給大家參考,以便對照程式碼的實作和實際的作業流程。



礙於篇幅的關係,整合 SAML2 SSO 的文章我會分成底下三篇來記錄,有需要的朋友再自行參考。


本文範例下載 : Github,使用 NetCore 8。


參考網站

留言