5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[C#]SAMLサンプル(.NET6)

Posted at

SAMLの認証プログラムのサンプルを作る機会があったので手順を記述します。

環境

  • Idp Okta
  • WSL2 Ubuntu22.04.01
  • Editor VSCode
  • Docker Image mcr.microsoft.com/dotnet/sdk

  • .NET .NET 6 C#
  • NuGet Package
    • ITfoxtec.Identity.Saml2 v4.8.2
    • ITfoxtec.Identity.Saml2.Mvc v4.8.2

参考サイト

環境構築

WSLに.NET6をインストールし、ASP.NETのプロジェクトを作成します。

$ sudo apt update && sudo apt install dotnet6
$ dotnet new webapp -o saml-example

saml-exampleディレクトリに入りappsettings.Development.jsonに以下の行を追加してローカルで起動するポートを固定します。

appsettings.Development.json
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
-   }
+   },
+   "Urls": "http://*:5000;https://*:5001"

Dockerの設定ファイルです。Dockerfileの中でNuGetからパッケージをインストールと証明書の作成をしています。

docker-compose.yml
version: '3.4'

services:
  webapp:
    image: samle-example-image
    build: .
    container_name: samle-example
    ports:
      - 5000:5000
      - 5001:5001
    volumes:
      - ./saml-example:/opt/saml-example
Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:6.0

# HTTPS通信のための証明書を作成しています。
RUN mkdir /https
RUN dotnet dev-certs https -ep /https/aspnetapp.pfx -p password

ENV ASPNETCORE_Kestrel__Certificates__Default__Password=password
ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx

COPY saml-example /opt/saml-example
WORKDIR /opt/saml-example
# NuGetパッケージをインストールしています。
RUN dotnet add package ITfoxtec.Identity.Saml2 --version 4.8.2
RUN dotnet add package ITfoxtec.Identity.Saml2.MvcCore --version 4.8.2

CMD [ "dotnet", "watch", "run" ]

今の構成

docker-compose.yml
Dockerfile
saml-example
  |- appsettings.Development.json
  |- 
  ...

ここまででASP.NETの初期画面が起動することを確認します。docker-compose up -dを実行し、 https://localhost:5001 にアクセスして確認してください。
image.png

プログラム実装

プログラムが長いので折りたたみにしました
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();

...

app.UseRouting();
+ app.MapDefaultControllerRoute();
+ app.UseSaml2();
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 saml_example.Identity;
+ using Microsoft.Extensions.Options;
+ using System.Security.Authentication;
+ 
+ namespace saml_example.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("~/");
+         }
+     }
+ }
ClaimsTransform.cs
+ using ITfoxtec.Identity.Saml2.Claims;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Security.Claims;
+ 
+ namespace saml_example.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;
+         }
+     }
+ }
_Layout.cshtml
- <li class="nav-item">
-     <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
- </li>
- <li class="nav-item">
-     <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
- </li>
+ @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>
+ }
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>
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 saml_example.Pages
+ {
+     [Authorize]
+     public class ClaimsModel : PageModel
+     {
+         private readonly ILogger<ClaimsModel> _logger;
+ 
+         public ClaimsModel(ILogger<ClaimsModel> logger)
+         {
+             _logger = logger;
+         }
+ 
+         public void OnGet()
+         {
+ 
+         }
+     }
+ }

Oktaアプリ作成

Applications > Create App Integration > SAML 2.0
App Name: 任意の値(ex. SAML_SAMPLE)
image.png
Single sign on URL: https://localhost:5001/Auth/AssertionConsumerService
Audience URI(SP Entity ID): 任意の値(ex. SAML_SAMPLE)
image.png
アプリ作成後、Sign OnタブのSAML Signing CertificatesのStatusがActive > Actions > View IdP metadataのリンクをコピーしておきます。
image.png

SAML設定

IdPMetadata: メタデータのURL
Issuer: Audience URI(SP Entity ID)と同じ値
CertificateValidationMode: None

appsettings.json
+  "AllowedHosts": "*",
+  "Saml2": {
+      "IdPMetadata": "https://trial-2479249.okta.com/app/exk3fk23t0ToAVGwU697/sso/saml/metadata",
+      "Issuer": "SAML_Example",
+      "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
+      "CertificateValidationMode": "None",
+      "RevocationMode": "NoCheck"
+  }

動作確認

Loginボタンをクリックします。
image.png
Oktaのログイン画面が表示されるのでログインします。
image.png
アプリにログインしOktaのユーザー名が表示されます。
image.png

まとめ

ここまでの構成です。編集したファイルのみ記述してます。

docker-compose.yml
Dockerfile
saml-example
  |- appsettings.Development.json
  |- appsettings.json
  |- Program.cs
  |- ClaimsTransform.cs
  |- Pages
    |- Claims.cshtml
    |- Claims.cshtml.cs
    |- Shared
      |- _Layout.cshtml
  |- Controllers
    |- AuthController.cs
  ...

この実装はSP-Initiatedです。以下のようなフローとなります。今回使用したパッケージはIdp-Initiatedにも対応しているようです。

個人の感想

日本製のSaaSだとSAML機能が付いているサービスがそもそも少なく、対応していたとしても追加料金で10,000/月以上、あるいはもっと、掛かる場合があってちょっとぼったくりかなぁと思ったりしました。
海外製はSAMLに対応しているサービスが多く、追加料金が掛からないイメージです。この辺に海外との差を感じました。SAMLくらいは標準プランで対応すべきです→日本企業。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?