SAMLのSPをIISでホストする(IDPはSustainsys のスタブIDPを使用)
Microsoft .NETの webapp でSAMLのSPを作成し、IISでホストする手順。
IDPはSustainsys のスタブIDPを使用し、下図のような構成を作る。
使用するライブラリ | ITfoxtec.Identity.SAML2 (https://saml2.sustainsys.com/en/v2/#) |
動作環境 | Windows 11 |
Idp | Sustainsys SAML IDP (https://stubidp.sustainsys.com/) |
参考URL | https://qiita.com/urushibata/items/85eb2d0e25b3cd290e14 https://developer.okta.com/blog/2020/10/23/how-to-authenticate-with-saml-in-aspnet-core-and-csharp |
■1. Windows に .NET SDKをインストールする。
以下のURLから、。NET SDKと .NET Core Runtime をダウンロードしてインストールする(バージョンを合わせること)
https://dotnet.microsoft.com/ja-jp/download
■2. webappを作成する
(2.1)以下のコマンドを実行して、Webappを作成する。
dotnet new webapp -o sample cd sample dotnet add package ITfoxtec.Identity.Saml2 --version 4.8.2 dotnet add package ITfoxtec.Identity.Saml2.MvcCore --version 4.8.2
(2.2)以下のソースコードを編集•追加する。(それ以外はデフォルトのまま)
(2.2.1) sample/appsettings.json
(IdpMetadataに、SustainsysのStub IDPを指定している)
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Saml2": { "IdPMetadata": "https://stubidp.sustainsys.com/Metadata", "Issuer": "http://saml_sample/", "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "CertificateValidationMode": "None", "RevocationMode": "NoCheck" } }
(2.2.2) sample/ClaimsTransform.cs
using ITfoxtec.Identity.Saml2.Claims; using System.Collections.Generic; using System.Linq; using System.Security.Claims; namespace sample.Identity { public static class ClaimsTransform { public static ClaimsPrincipal Transform(ClaimsPrincipal incomingPrincipal) { if (!incomingPrincipal.Identity.IsAuthenticated) { return incomingPrincipal; } return CreateClaimsPrincipal(incomingPrincipal); } private static ClaimsPrincipal CreateClaimsPrincipal(ClaimsPrincipal incomingPrincipal) { var claims = new List<Claim>(); // All claims claims.AddRange(incomingPrincipal.Claims); // Or custom claims //claims.AddRange(GetSaml2LogoutClaims(incomingPrincipal)); //claims.Add(new Claim(ClaimTypes.NameIdentifier, GetClaimValue(incomingPrincipal, ClaimTypes.NameIdentifier))); return new ClaimsPrincipal(new ClaimsIdentity(claims, incomingPrincipal.Identity.AuthenticationType, ClaimTypes.NameIdentifier, ClaimTypes.Role) { BootstrapContext = ((ClaimsIdentity)incomingPrincipal.Identity).BootstrapContext }); } private static IEnumerable<Claim> GetSaml2LogoutClaims(ClaimsPrincipal principal) { yield return GetClaim(principal, Saml2ClaimTypes.NameId); yield return GetClaim(principal, Saml2ClaimTypes.NameIdFormat); yield return GetClaim(principal, Saml2ClaimTypes.SessionIndex); } private static Claim GetClaim(ClaimsPrincipal principal, string claimType) { return ((ClaimsIdentity)principal.Identity).Claims.Where(c => c.Type == claimType).FirstOrDefault(); } private static string GetClaimValue(ClaimsPrincipal principal, string claimType) { var claim = GetClaim(principal, claimType); return claim != null ? claim.Value : null; } } }
(2.2.3) sample/Program.cs
using ITfoxtec.Identity.Saml2; using ITfoxtec.Identity.Saml2.Schemas.Metadata; using ITfoxtec.Identity.Saml2.MvcCore.Configuration; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorPages(); builder.Services.Configure<Saml2Configuration>(builder.Configuration.GetSection("Saml2")); builder.Services.Configure<Saml2Configuration>(saml2Configuration => { saml2Configuration.AllowedAudienceUris.Add(saml2Configuration.Issuer); var entityDescriptor = new EntityDescriptor(); entityDescriptor.ReadIdPSsoDescriptorFromUrl(new Uri(builder.Configuration["Saml2:IdPMetadata"])); if (entityDescriptor.IdPSsoDescriptor != null) { saml2Configuration.SingleSignOnDestination = entityDescriptor.IdPSsoDescriptor.SingleSignOnServices.First().Location; saml2Configuration.SignatureValidationCertificates.AddRange(entityDescriptor.IdPSsoDescriptor.SigningCertificates); } else { throw new Exception("IdPSsoDescriptor not loaded from metadata."); } }); builder.Services.AddSaml2(); var app = builder.Build(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseHttpsRedirection(); // HTTPSを強制したくない場合は、この行をコメントアウト app.UseStaticFiles(); app.UseRouting(); app.MapDefaultControllerRoute(); app.UseSaml2(); app.UseAuthorization(); app.MapRazorPages(); app.Run();
(2.2.4) sample/Controllers/AuthController.cs
using ITfoxtec.Identity.Saml2; using ITfoxtec.Identity.Saml2.Schemas; using ITfoxtec.Identity.Saml2.MvcCore; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using sample.Identity; using Microsoft.Extensions.Options; using System.Security.Authentication; namespace sample.Controllers { [AllowAnonymous] [Route("Auth")] public class AuthController : Controller { const string relayStateReturnUrl = "ReturnUrl"; private readonly Saml2Configuration config; public AuthController(IOptions<Saml2Configuration> configAccessor) { config = configAccessor.Value; } [Route("Login")] public IActionResult Login(string returnUrl = null) { var binding = new Saml2RedirectBinding(); binding.SetRelayStateQuery(new Dictionary<string, string> { { relayStateReturnUrl, returnUrl ?? Url.Content("~/") } }); return binding.Bind(new Saml2AuthnRequest(config)).ToActionResult(); } [Route("AssertionConsumerService")] public async Task<IActionResult> AssertionConsumerService() { var binding = new Saml2PostBinding(); var saml2AuthnResponse = new Saml2AuthnResponse(config); binding.ReadSamlResponse(Request.ToGenericHttpRequest(), saml2AuthnResponse); if (saml2AuthnResponse.Status != Saml2StatusCodes.Success) { throw new AuthenticationException($"SAML Response status: {saml2AuthnResponse.Status}"); } binding.Unbind(Request.ToGenericHttpRequest(), saml2AuthnResponse); await saml2AuthnResponse.CreateSession(HttpContext, claimsTransform: (claimsPrincipal) => ClaimsTransform.Transform(claimsPrincipal)); var relayStateQuery = binding.GetRelayStateQuery(); var returnUrl = relayStateQuery.ContainsKey(relayStateReturnUrl) ? relayStateQuery[relayStateReturnUrl] : Url.Content("~/"); return Redirect(returnUrl); } [HttpPost("Logout")] [ValidateAntiForgeryToken] public async Task<IActionResult> Logout() { if (!User.Identity.IsAuthenticated) { return Redirect(Url.Content("~/")); } var binding = new Saml2PostBinding(); var saml2LogoutRequest = await new Saml2LogoutRequest(config, User).DeleteSession(HttpContext); return Redirect("~/"); } } }
(2.2.5) sample/Pages/Claims.cshtml
@page @model ClaimsModel @{ ViewData["Title"] = "Home page"; } <div class="row"> <div class="col-md-12"> <h2>The users Claims (Iteration on User.Claims)</h2> <p> @foreach (var claim in User.Claims) { <strong>@claim.Type</strong> <br /> <span style="padding-left: 10px">Value: @claim.Value</span> <br /> } </p> </div> </div>
(2.2.6) sample/Pages/Claims.cshtml.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; namespace sample.Pages { [Authorize] public class ClaimsModel : PageModel { private readonly ILogger<ClaimsModel> _logger; public ClaimsModel(ILogger<ClaimsModel> logger) { _logger = logger; } public void OnGet() { } } }
(2.2.7) sample/Pages/Shared/_Layout.cshtml
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] - saml_example</title> <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> </head> <body> <header> <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3"> <div class="container"> <a class="navbar-brand" asp-area="" asp-page="/Index">sample</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between"> <ul class="navbar-nav flex-grow-1"> @if (((System.Security.Claims.ClaimsIdentity)User.Identity).IsAuthenticated) { <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-page="/Claims">SAML Claims</a> </li> <li> @if (User.Identity.Name != null) { <span class="navbar-text">Hello, @User.Identity.Name!</span> } else { <span class="navbar-text">Hello</span> } </li> <li> <form class="form-inline" asp-controller="Auth" asp-action="Logout"> <button type="submit" class="nav-link btn btn-link text-dark">Logout</button> </form> </li> } else { <li class="nav-item"> <a class="nav-link text-dark" asp-controller="Auth" asp-action="Login">Login</a> </li> } </ul> </div> </div> </nav> </header> <div class="container"> <main role="main" class="pb-3"> @RenderBody() </main> </div> <footer class="border-top footer text-muted"> <div class="container"> © 2025 - saml_example - <a asp-area="" asp-page="/Privacy">Privacy</a> </div> </footer> <script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="~/js/site.js" asp-append-version="true"></script> @await RenderSectionAsync("Scripts", required: false) </body> </html>
■3. ビルドとデプロイ用のリリース物件を作成
(3.1)以下のコマンドを実行し、ビルドとIISにデプロイする物件を作成する。
dotnet build dotnet publish --configuration Release
上記を実行すると、sample¥bin¥Release¥netX.0¥publish 配下にデプロイファイル群一式ができる。
■4. IISにアプリケーションとして追加
(4.1)IISマネージャ起動
(4.2)アプリケーションプールを新規作成(デフォルト設定でOK)
(4.3)Default Web Siteを右クリックして、「アプリケーションの追加」を選択
(4.4)上記■3. で作成したアプリケーションフォルダを選択
■5. 動かし方
エイリアスを、”sapmple”とした場合、WEBブラウザで以下のようにアクセス。
(5.1)https://localhost/sample/
(5.2)下図のようにIDPで認証する。
■6. その他
(6.1) メタデータ取得に失敗する場合
もしこのSPを会社のイントラネット配下で動かした場合に、下図ように、Proxyサーバを経由したmetadataの取得に失敗するかもしれない。
その場合の回避策の1つとして、以下からメタデータをダウンロードし、
https://stubidp.sustainsys.com/Metadata
それを、今回作成したアプリケーションの、wwwroot配下に、metadata.xmlとして保存し、appilcation.jsonを以下のように変更することで回避することが可能。
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Saml2": { "IdPMetadata":"https://localhost/sample/metadata.xml", // "https://stubidp.sustainsys.com/Metadata", "Issuer": "http://saml_sample/", "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "CertificateValidationMode": "None", "RevocationMode": "NoCheck" } }
(6.2) 署名のチェック
今回の例では、署名のチェックをしていない。署名のチェックをする場合は、以下のようにCertificateValidationMode を変更する。ただし、その場合はIDPの公開鍵を信頼済みルートCAとして登録する必要がある。(studbidpの公開鍵を信頼済みルートCAに登録する場合は、開発環境にとどめるなど注意が必要。本番環境でやってしまうと、不特定多数の人間が任意のユーザになりすますことができてしまう)
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Saml2": { "IdPMetadata":"https://localhost/sample/metadata.xml", // "https://stubidp.sustainsys.com/Metadata", "Issuer": "http://saml_sample/", "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "CertificateValidationMode": "ChainTrust", "RevocationMode": "NoCheck" } }